From c2e18d7b2c2c458e6951fc88f17e1ec0ebc13884 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Fri, 18 Oct 2024 13:08:23 -0700 Subject: [PATCH 01/48] Adding allocation id --- .../Extensions/StringExtensions.cs | 9 + .../FeatureManagementConstants.cs | 1 + .../FeatureManagementKeyValueAdapter.cs | 86 +++++++++ .../FeatureManagementTests.cs | 164 ++++++++++++++++++ 4 files changed, 260 insertions(+) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs index 7bcf7212c..8b2c488d0 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System; + namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class LabelFilters @@ -16,5 +18,12 @@ public static string NormalizeNull(this string s) { return s == LabelFilters.Null ? null : s; } + + public static string ToBase64String(this string s) + { + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(s); + + return Convert.ToBase64String(bytes); + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs index c6d86d849..aa573a1e2 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs @@ -44,6 +44,7 @@ internal class FeatureManagementConstants public const string ETag = "ETag"; public const string FeatureFlagId = "FeatureFlagId"; public const string FeatureFlagReference = "FeatureFlagReference"; + public const string AllocationId = "AllocationId"; // Dotnet schema keys public const string DotnetSchemaSectionName = "FeatureManagement"; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index 2b0e08331..9e3678844 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -319,12 +319,98 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.ETag}", setting.ETag.ToString())); keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Enabled}", telemetry.Enabled.ToString())); + + string allocationId = CalculateAllocationId(featureFlag); + + if (allocationId != null) + { + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.AllocationId}", allocationId)); + } } } return keyValues; } + private string CalculateAllocationId(FeatureFlag flag) + { + if (flag.Allocation == null) + { + return null; + } + + StringBuilder inputBuilder = new StringBuilder(); + + // Seed + inputBuilder.Append($"seed={flag.Allocation.Seed ?? ""}"); + + var allocatedVariants = new HashSet(); + + // DefaultWhenEnabled + if (flag.Allocation.DefaultWhenEnabled != null) + { + allocatedVariants.Add(flag.Allocation.DefaultWhenEnabled); + } + + inputBuilder.Append($"\ndefault_when_enabled={flag.Allocation.DefaultWhenEnabled ?? ""}"); + + // Percentiles + inputBuilder.Append("\npercentiles="); + + if (flag.Allocation.Percentile != null && flag.Allocation.Percentile.Any()) + { + var sortedPercentiles = flag.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.ToBase64String()},{p.To}"))); + } + + // If there's no custom seed and no variants allocated, stop now and return null + if (flag.Allocation.Seed == null && + !allocatedVariants.Any()) + { + return null; + } + + // Variants + inputBuilder.Append("\nvariants="); + + if (allocatedVariants.Any() && flag.Variants != null && flag.Variants.Any()) + { + var sortedVariants = flag.Variants + .Where(variant => allocatedVariants.Contains(variant.Name)) + .OrderBy(variant => variant.Name) + .ToList(); + + inputBuilder.Append(string.Join(";", sortedVariants.Select(v => + { + var variantValue = ""; + + if (v.ConfigurationValue.ValueKind != JsonValueKind.Null && v.ConfigurationValue.ValueKind != JsonValueKind.Undefined) + { + variantValue = JsonSerializer.Serialize(v.ConfigurationValue); + } + + return $"{v.Name.ToBase64String()},{(variantValue)}"; + }))); + } + + // Example input string + // input == "seed=123abc\ndefault_when_enabled=Control\npercentiles=0,Blshdk,20;20,Test,100\nvariants=TdLa,standard;Qfcd,special" + string input = inputBuilder.ToString(); + + using (SHA256 sha256 = SHA256.Create()) + { + byte[] truncatedHash = new byte[15]; + Array.Copy(sha256.ComputeHash(Encoding.UTF8.GetBytes(input)), truncatedHash, 15); + return truncatedHash.ToBase64Url(); + } + } + private FormatException CreateFeatureFlagFormatException(string jsonPropertyName, string settingKey, string foundJsonValueKind, string expectedJsonValueKind) { return new FormatException(string.Format( diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 44c20b4d0..2bfc415e5 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -622,6 +622,111 @@ public class FeatureManagementTests eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) }; + List _allocationIdFeatureFlagCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "TelemetryVariant", + value: @" + { + ""id"": ""TelemetryVariant"", + ""enabled"": true, + ""variants"": [ + { + ""name"": ""True_Override"", + ""configuration_value"": ""default"", + ""status_override"": ""Disabled"" + } + ], + ""allocation"": { + ""default_when_enabled"": ""True_Override"" + }, + ""telemetry"": { + ""enabled"": ""true"" + } + } + ", + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("cmwBRcIAq1jUyKL3Kj8bvf9jtxBrFg-R-ayExStMC90")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "TelemetryVariantPercentile", + value: @" + { + ""id"": ""TelemetryVariant"", + ""enabled"": true, + ""variants"": [ + { + ""name"": ""True_Override"", + ""configuration_value"": { + ""someKey"": ""someValue"", + ""someOtherKey"": { + ""someSubKey"": ""someSubValue"" + } + } + } + ], + ""allocation"": { + ""default_when_enabled"": ""True_Override"", + ""percentile"": [ + { + ""variant"": ""True_Override"", + ""from"": 0, + ""to"": 100 + } + ] + }, + ""telemetry"": { + ""enabled"": ""true"" + } + } + ", + label: "label", + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("cmwBRcIAq1jUyKL3Kj8bvf9jtxBrFg-R-ayExStMC90")), + + // Quote of the day test + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "Greeting", + value: @" + { + ""id"": ""Greeting"", + ""description"": """", + ""enabled"": true, + ""variants"": [ + { + ""name"": ""On"", + ""configuration_value"": true + }, + { + ""name"": ""Off"", + ""configuration_value"": false + } + ], + ""allocation"": { + ""percentile"": [ + { + ""variant"": ""On"", + ""from"": 0, + ""to"": 50 + }, + { + ""variant"": ""Off"", + ""from"": 50, + ""to"": 100 + } + ], + ""default_when_enabled"": ""Off"", + ""default_when_disabled"": ""Off"" + }, + ""telemetry"": { + ""enabled"": true + } + } + ", + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("8kS3pc_cQmWnfLY9LQ1cd-RfR6_nQqH6sgdlL9eCgek")), + }; + TimeSpan RefreshInterval = TimeSpan.FromSeconds(1); [Fact] @@ -1972,6 +2077,65 @@ public void WithTelemetry() Assert.Equal("Tag2Value", config["feature_management:feature_flags:1:telemetry:metadata:Tags.Tag1"]); } + [Fact] + public void WithAllocationId() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(_allocationIdFeatureFlagCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Connect(TestHelpers.PrimaryConfigStoreEndpoint, new DefaultAzureCredential()); + options.UseFeatureFlags(); + }) + .Build(); + + byte[] featureFlagIdHash; + + using (HashAlgorithm hashAlgorithm = SHA256.Create()) + { + featureFlagIdHash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes($"{FeatureManagementConstants.FeatureFlagMarker}TelemetryVariant\n")); + } + + string featureFlagId = Convert.ToBase64String(featureFlagIdHash) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + // Validate TelemetryVariant + Assert.Equal("True", config["feature_management:feature_flags:0:telemetry:enabled"]); + Assert.Equal("TelemetryVariant", config["feature_management:feature_flags:0:id"]); + + Assert.Equal(featureFlagId, config["feature_management:feature_flags:0:telemetry:metadata:FeatureFlagId"]); + + Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}TelemetryVariant", config["feature_management:feature_flags:0:telemetry:metadata:FeatureFlagReference"]); + + Assert.Equal("MExY1waco2tqen4EcJKK", config["feature_management:feature_flags:0:telemetry:metadata:AllocationId"]); + + // Validate TelemetryVariantPercentile + Assert.Equal("True", config["feature_management:feature_flags:1:telemetry:enabled"]); + Assert.Equal("TelemetryVariant", config["feature_management:feature_flags:1:id"]); + + Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}TelemetryVariantPercentile?label=label", config["feature_management:feature_flags:1:telemetry:metadata:FeatureFlagReference"]); + + Assert.Equal("SCq5T7Bb1k6tWfWjI3qz", config["feature_management:feature_flags:1:telemetry:metadata:AllocationId"]); + + // Validate Greeting + Assert.Equal("True", config["feature_management:feature_flags:2:telemetry:enabled"]); + Assert.Equal("Greeting", config["feature_management:feature_flags:2:id"]); + + Assert.Equal("63pHsrNKDSi5Zfe_FvZPSegwbsEo5TS96hf4k7cc4Zw", config["feature_management:feature_flags:2:telemetry:metadata:FeatureFlagId"]); + + Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}Greeting", config["feature_management:feature_flags:2:telemetry:metadata:FeatureFlagReference"]); + + Assert.Equal("L0m7_ulkdsaQmz6dSw4r", config["feature_management:feature_flags:2:telemetry:metadata:AllocationId"]); + } + [Fact] public void WithRequirementType() { From 1c2c525eee10bf6cb1c7695e1df8bee1504e45fb Mon Sep 17 00:00:00 2001 From: Sami Sadfa Date: Mon, 21 Oct 2024 14:59:24 -0700 Subject: [PATCH 02/48] serialize with sorted keys --- .../FeatureManagementKeyValueAdapter.cs | 6 +- .../JsonElementExtensions.cs | 87 +++++++++++++++++++ .../FeatureManagementTests.cs | 4 +- 3 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index 9e3678844..3076cd996 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -359,7 +359,7 @@ private string CalculateAllocationId(FeatureFlag flag) if (flag.Allocation.Percentile != null && flag.Allocation.Percentile.Any()) { - var sortedPercentiles = flag.Allocation.Percentile + IEnumerable sortedPercentiles = flag.Allocation.Percentile .Where(p => p.From != p.To) .OrderBy(p => p.From) .ToList(); @@ -381,7 +381,7 @@ private string CalculateAllocationId(FeatureFlag flag) if (allocatedVariants.Any() && flag.Variants != null && flag.Variants.Any()) { - var sortedVariants = flag.Variants + IEnumerable sortedVariants = flag.Variants .Where(variant => allocatedVariants.Contains(variant.Name)) .OrderBy(variant => variant.Name) .ToList(); @@ -392,7 +392,7 @@ private string CalculateAllocationId(FeatureFlag flag) if (v.ConfigurationValue.ValueKind != JsonValueKind.Null && v.ConfigurationValue.ValueKind != JsonValueKind.Undefined) { - variantValue = JsonSerializer.Serialize(v.ConfigurationValue); + variantValue = v.ConfigurationValue.SerializeWithSortedKeys(); } return $"{v.Name.ToBase64String()},{(variantValue)}"; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs new file mode 100644 index 000000000..a4be3b2a3 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement +{ + internal static class JsonElementExtensions + { + public static string SerializeWithSortedKeys(this JsonElement rootElement) + { + using var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false })) + { + WriteElementWithSortedKeys(rootElement, writer); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteElementWithSortedKeys(JsonElement element, Utf8JsonWriter writer) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + writer.WriteStartObject(); + + foreach (JsonProperty property in element.EnumerateObject().OrderBy(p => p.Name)) + { + writer.WritePropertyName(property.Name); + WriteElementWithSortedKeys(property.Value, writer); + } + + writer.WriteEndObject(); + break; + + case JsonValueKind.Array: + writer.WriteStartArray(); + + foreach (JsonElement item in element.EnumerateArray()) + { + WriteElementWithSortedKeys(item, writer); + } + + writer.WriteEndArray(); + break; + + case JsonValueKind.String: + writer.WriteStringValue(element.GetString()); + break; + + case JsonValueKind.Number: + if (element.TryGetInt32(out int intValue)) + { + writer.WriteNumberValue(intValue); + } + else if (element.TryGetInt64(out long longValue)) + { + writer.WriteNumberValue(longValue); + } + else + { + writer.WriteNumberValue(element.GetDouble()); + } + + break; + + case JsonValueKind.True: + writer.WriteBooleanValue(true); + break; + + case JsonValueKind.False: + writer.WriteBooleanValue(false); + break; + + case JsonValueKind.Null: + writer.WriteNullValue(); + break; + + default: + throw new InvalidOperationException($"Unsupported JsonValueKind: {element.ValueKind}"); + } + } + } +} diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 2bfc415e5..0a94c0f4a 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -658,10 +658,10 @@ public class FeatureManagementTests { ""name"": ""True_Override"", ""configuration_value"": { - ""someKey"": ""someValue"", ""someOtherKey"": { ""someSubKey"": ""someSubValue"" - } + }, + ""someKey"": ""someValue"" } } ], From cb4e61558b86125c4dda0b1c8fbb5975b80a404f Mon Sep 17 00:00:00 2001 From: Sami Sadfa Date: Mon, 21 Oct 2024 15:08:09 -0700 Subject: [PATCH 03/48] use string empty --- .../FeatureManagement/FeatureManagementKeyValueAdapter.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index 3076cd996..dcfc70d55 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -342,7 +342,7 @@ private string CalculateAllocationId(FeatureFlag flag) StringBuilder inputBuilder = new StringBuilder(); // Seed - inputBuilder.Append($"seed={flag.Allocation.Seed ?? ""}"); + inputBuilder.Append($"seed={flag.Allocation.Seed ?? string.Empty}"); var allocatedVariants = new HashSet(); @@ -352,7 +352,7 @@ private string CalculateAllocationId(FeatureFlag flag) allocatedVariants.Add(flag.Allocation.DefaultWhenEnabled); } - inputBuilder.Append($"\ndefault_when_enabled={flag.Allocation.DefaultWhenEnabled ?? ""}"); + inputBuilder.Append($"\ndefault_when_enabled={flag.Allocation.DefaultWhenEnabled ?? string.Empty}"); // Percentiles inputBuilder.Append("\npercentiles="); @@ -388,7 +388,7 @@ private string CalculateAllocationId(FeatureFlag flag) inputBuilder.Append(string.Join(";", sortedVariants.Select(v => { - var variantValue = ""; + var variantValue = string.Empty; if (v.ConfigurationValue.ValueKind != JsonValueKind.Null && v.ConfigurationValue.ValueKind != JsonValueKind.Undefined) { From 49ef1fc8b8ae75911835d30a0f25fa3360cbffd1 Mon Sep 17 00:00:00 2001 From: Sami Sadfa Date: Mon, 21 Oct 2024 15:11:51 -0700 Subject: [PATCH 04/48] nit --- .../FeatureManagementKeyValueAdapter.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index dcfc70d55..c29827b97 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Security.Cryptography; using System.Text; @@ -320,11 +321,14 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Enabled}", telemetry.Enabled.ToString())); - string allocationId = CalculateAllocationId(featureFlag); - - if (allocationId != null) + if (featureFlag.Allocation != null) { - keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.AllocationId}", allocationId)); + string allocationId = CalculateAllocationId(featureFlag); + + if (allocationId != null) + { + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.AllocationId}", allocationId)); + } } } } @@ -334,10 +338,7 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea private string CalculateAllocationId(FeatureFlag flag) { - if (flag.Allocation == null) - { - return null; - } + Debug.Assert(flag.Allocation != null); StringBuilder inputBuilder = new StringBuilder(); From 86fde179dd388012c0a1523e3e8aa885a0500714 Mon Sep 17 00:00:00 2001 From: Sami Sadfa Date: Mon, 21 Oct 2024 15:37:52 -0700 Subject: [PATCH 05/48] rename ff id to TelemetryVariantPercentile --- tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 0a94c0f4a..235aec842 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -652,7 +652,7 @@ public class FeatureManagementTests key: FeatureManagementConstants.FeatureFlagMarker + "TelemetryVariantPercentile", value: @" { - ""id"": ""TelemetryVariant"", + ""id"": ""TelemetryVariantPercentile"", ""enabled"": true, ""variants"": [ { @@ -2119,7 +2119,7 @@ public void WithAllocationId() // Validate TelemetryVariantPercentile Assert.Equal("True", config["feature_management:feature_flags:1:telemetry:enabled"]); - Assert.Equal("TelemetryVariant", config["feature_management:feature_flags:1:id"]); + Assert.Equal("TelemetryVariantPercentile", config["feature_management:feature_flags:1:id"]); Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}TelemetryVariantPercentile?label=label", config["feature_management:feature_flags:1:telemetry:metadata:FeatureFlagReference"]); From c191d7f5666b11ba20156932ddff9c50f88c4a00 Mon Sep 17 00:00:00 2001 From: Sami Sadfa Date: Mon, 21 Oct 2024 15:58:15 -0700 Subject: [PATCH 06/48] add more values --- .../FeatureManagement/JsonElementExtensions.cs | 7 +++++-- .../Tests.AzureAppConfiguration/FeatureManagementTests.cs | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs index a4be3b2a3..88fb4d5c3 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs @@ -60,11 +60,14 @@ private static void WriteElementWithSortedKeys(JsonElement element, Utf8JsonWrit { writer.WriteNumberValue(longValue); } - else + else if (element.TryGetDecimal(out decimal decimalValue)) + { + writer.WriteNumberValue(element.GetDecimal()); + } + else if (element.TryGetDouble(out double doubleValue)) { writer.WriteNumberValue(element.GetDouble()); } - break; case JsonValueKind.True: diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 235aec842..7e49e8abd 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -661,7 +661,10 @@ public class FeatureManagementTests ""someOtherKey"": { ""someSubKey"": ""someSubValue"" }, - ""someKey"": ""someValue"" + ""someKey4"": [3, 1, 4, true], + ""someKey"": ""someValue"", + ""someKey3"": 3.14, + ""someKey2"": 3 } } ], @@ -2123,7 +2126,7 @@ public void WithAllocationId() Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}TelemetryVariantPercentile?label=label", config["feature_management:feature_flags:1:telemetry:metadata:FeatureFlagReference"]); - Assert.Equal("SCq5T7Bb1k6tWfWjI3qz", config["feature_management:feature_flags:1:telemetry:metadata:AllocationId"]); + Assert.Equal("YsdJ4pQpmhYa8KEhRLUn", config["feature_management:feature_flags:1:telemetry:metadata:AllocationId"]); // Validate Greeting Assert.Equal("True", config["feature_management:feature_flags:2:telemetry:enabled"]); From 603cd0f9901b7714d5ae89385d24433330111ee2 Mon Sep 17 00:00:00 2001 From: Sami Sadfa Date: Mon, 21 Oct 2024 16:09:15 -0700 Subject: [PATCH 07/48] dotnet format --- .../FeatureManagement/JsonElementExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs index 88fb4d5c3..fc7f8b26d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs @@ -68,6 +68,7 @@ private static void WriteElementWithSortedKeys(JsonElement element, Utf8JsonWrit { writer.WriteNumberValue(element.GetDouble()); } + break; case JsonValueKind.True: From 35386d0f18bc2cc000258f3da6d47d1ca2748708 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Wed, 23 Oct 2024 11:48:59 -0700 Subject: [PATCH 08/48] Version bump --- .../Microsoft.Azure.AppConfiguration.AspNetCore.csproj | 2 +- .../Microsoft.Azure.AppConfiguration.Functions.Worker.csproj | 2 +- ...rosoft.Extensions.Configuration.AzureAppConfiguration.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj index 1801f8d7d..a5d4dee4e 100644 --- a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj +++ b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj @@ -21,7 +21,7 @@ - 8.0.0-preview.3 + 8.1.0-preview diff --git a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj index 3926c5fd8..4354b77ec 100644 --- a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj +++ b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj @@ -24,7 +24,7 @@ - 8.0.0-preview.3 + 8.1.0-preview diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index 6387a82ff..aeedd6e67 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -35,7 +35,7 @@ - 8.0.0-preview.3 + 8.1.0-preview From 5e6a012e18959f4eed4a82057a7561b081f1d33e Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:51:56 -0800 Subject: [PATCH 09/48] Add `RegisterAll` API to enable monitoring collections of key-values for refresh (#574) * WIP * WIP testing out client extensions methods * WIP added selectors to multikeywatchers * remove unused property * WIP check for registerall changes to change refreshall * WIP * WIP fixing types and reslving errors * WIP fixing client extensions class * WIP * WIP update feature flag logic * WIP client extensions * WIP reload all flags on change * WIP * WIP fixing tests to return response for getconfigurationsettingsasync * WIP etag for tests * fix watchedcollections null * WIP tests, working for examples * remove unused variables * update to newest sdk version, remove unused * WIP fixing tests * WIP reworking testing to work with new etag approach * tests passing, fix mockasyncpageable * update sdk package version * fix loghelper, tests * WIP fixing aspages tests * revert watchesfeatureflags test * update test again * WIP * fixing watchconditions * separate selected key value collections from feature flag collections, separate selectors, add new methods to support new logic * comment and naming updates * fixing unit tests, namespace of defining/calling code needs to be same * fixing tests using AsPages * fix tests with pageablemanager * format * fix tests * fix tests * remove unused extension test class * fix comment, capitalization * check etag on 200, fix tests * add registerall test, fix refresh tests * fix condition for pages and old match conditions * WIP fixing PR comments, tests * check status after advancing existing etag enumerator * move around refresh logic * null check page etag, revert break to existing keys check in getrefreshedcollections * fix loadselected, replace selectedkvwatchers with registerall refresh time * fix comment in options * clean up tests * PR comments * PR comments * don't allow both registerall and register * fix check for calls to both register methods * PR comments for rename/small changes * fix compile error * simplify refreshasync path, fix naming from comments * remove redundant if check * simplify logic for minrefreshinterval * fix smaller comments * call loadselected when refreshing collection, separate data for individual refresh * in progress change to registerall include ff * fix load order * fix comments, rename logging constants to match new behavior * pr comments, refactor refreshasync * clean up etags dictionary creation * PR comments * add uncommitted changes to testhelper * update tests for registerall with feature flags, check ff keys to remove flags on refresh * PR comments * PR comments * use invalidoperationexception in configurerefresh, update loggingconstants to match behavior * remove unused changes --- .../AzureAppConfigurationOptions.cs | 113 ++- .../AzureAppConfigurationProvider.cs | 654 +++++++++++------- .../AzureAppConfigurationRefreshOptions.cs | 12 + .../ConfigurationSettingPageExtensions.cs | 33 + .../Constants/LoggingConstants.cs | 5 +- .../ConfigurationClientExtensions.cs | 129 +--- .../Extensions/StringExtensions.cs | 9 +- .../FeatureManagement/FeatureFlagOptions.cs | 3 +- .../GetKeyValueChangeCollectionOptions.cs | 13 - .../IConfigurationSettingPageIterator.cs | 13 + .../LogHelper.cs | 15 +- .../Models/KeyValueSelector.cs | 5 + .../Azure.Core.Testing/MockResponse.cs | 2 + .../FeatureManagementTests.cs | 100 ++- .../KeyVaultReferenceTests.cs | 4 +- .../RefreshTests.cs | 165 +++++ .../Tests.AzureAppConfiguration/TestHelper.cs | 60 +- 17 files changed, 865 insertions(+), 470 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs delete mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/GetKeyValueChangeCollectionOptions.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 7d9a9cad7..9391f21b1 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -23,12 +23,13 @@ public class AzureAppConfigurationOptions private const int MaxRetries = 2; private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); - private List _changeWatchers = new List(); - private List _multiKeyWatchers = new List(); + private List _individualKvWatchers = new List(); + private List _ffWatchers = new List(); private List _adapters; private List>> _mappers = new List>>(); - private List _kvSelectors = new List(); + private List _selectors; private IConfigurationRefresher _refresher = new AzureAppConfigurationRefresher(); + private bool _selectCalled = false; // The following set is sorted in descending order. // Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. @@ -62,19 +63,29 @@ public class AzureAppConfigurationOptions internal TokenCredential Credential { get; private set; } /// - /// A collection of . + /// A collection of specified by user. /// - internal IEnumerable KeyValueSelectors => _kvSelectors; + internal IEnumerable Selectors => _selectors; + + /// + /// Indicates if was called. + /// + internal bool RegisterAllEnabled { get; private set; } + + /// + /// Refresh interval for selected key-value collections when is called. + /// + internal TimeSpan KvCollectionRefreshInterval { get; private set; } /// /// A collection of . /// - internal IEnumerable ChangeWatchers => _changeWatchers; + internal IEnumerable IndividualKvWatchers => _individualKvWatchers; /// /// A collection of . /// - internal IEnumerable MultiKeyWatchers => _multiKeyWatchers; + internal IEnumerable FeatureFlagWatchers => _ffWatchers; /// /// A collection of . @@ -96,11 +107,15 @@ internal IEnumerable Adapters internal IEnumerable KeyPrefixes => _keyPrefixes; /// - /// An optional configuration client manager that can be used to provide clients to communicate with Azure App Configuration. + /// For use in tests only. An optional configuration client manager that can be used to provide clients to communicate with Azure App Configuration. /// - /// This property is used only for unit testing. internal IConfigurationClientManager ClientManager { get; set; } + /// + /// For use in tests only. An optional class used to process pageable results from Azure App Configuration. + /// + internal IConfigurationSettingPageIterator ConfigurationSettingPageIterator { get; set; } + /// /// An optional timespan value to set the minimum backoff duration to a value other than the default. /// @@ -142,6 +157,9 @@ public AzureAppConfigurationOptions() new JsonKeyValueAdapter(), new FeatureManagementKeyValueAdapter(FeatureFlagTracing) }; + + // Adds the default query to App Configuration if and are never called. + _selectors = new List { new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null } }; } /// @@ -170,22 +188,30 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter throw new ArgumentNullException(nameof(keyFilter)); } + // Do not support * and , for label filter for now. + if (labelFilter != null && (labelFilter.Contains('*') || labelFilter.Contains(','))) + { + throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); + } + if (string.IsNullOrWhiteSpace(labelFilter)) { labelFilter = LabelFilter.Null; } - // Do not support * and , for label filter for now. - if (labelFilter.Contains('*') || labelFilter.Contains(',')) + if (!_selectCalled) { - throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); + _selectors.Clear(); + + _selectCalled = true; } - _kvSelectors.AppendUnique(new KeyValueSelector + _selectors.AppendUnique(new KeyValueSelector { KeyFilter = keyFilter, LabelFilter = labelFilter }); + return this; } @@ -201,7 +227,14 @@ public AzureAppConfigurationOptions SelectSnapshot(string name) throw new ArgumentNullException(nameof(name)); } - _kvSelectors.AppendUnique(new KeyValueSelector + if (!_selectCalled) + { + _selectors.Clear(); + + _selectCalled = true; + } + + _selectors.AppendUnique(new KeyValueSelector { SnapshotName = name }); @@ -212,7 +245,7 @@ public AzureAppConfigurationOptions SelectSnapshot(string name) /// /// Configures options for Azure App Configuration feature flags that will be parsed and transformed into feature management configuration. /// If no filtering is specified via the then all feature flags with no label are loaded. - /// All loaded feature flags will be automatically registered for refresh on an individual flag level. + /// All loaded feature flags will be automatically registered for refresh as a collection. /// /// A callback used to configure feature flag options. public AzureAppConfigurationOptions UseFeatureFlags(Action configure = null) @@ -237,25 +270,22 @@ public AzureAppConfigurationOptions UseFeatureFlags(Action c options.FeatureFlagSelectors.Add(new KeyValueSelector { KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", - LabelFilter = options.Label == null ? LabelFilter.Null : options.Label + LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, + IsFeatureFlagSelector = true }); } - foreach (var featureFlagSelector in options.FeatureFlagSelectors) + foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) { - var featureFlagFilter = featureFlagSelector.KeyFilter; - var labelFilter = featureFlagSelector.LabelFilter; + _selectors.AppendUnique(featureFlagSelector); - Select(featureFlagFilter, labelFilter); - - _multiKeyWatchers.AppendUnique(new KeyValueWatcher + _ffWatchers.AppendUnique(new KeyValueWatcher { - Key = featureFlagFilter, - Label = labelFilter, + Key = featureFlagSelector.KeyFilter, + Label = featureFlagSelector.LabelFilter, // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins RefreshInterval = options.RefreshInterval }); - } return this; @@ -376,18 +406,41 @@ public AzureAppConfigurationOptions ConfigureClientOptions(ActionA callback used to configure Azure App Configuration refresh options. public AzureAppConfigurationOptions ConfigureRefresh(Action configure) { + if (RegisterAllEnabled) + { + throw new InvalidOperationException($"{nameof(ConfigureRefresh)}() cannot be invoked multiple times when {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} has been invoked."); + } + var refreshOptions = new AzureAppConfigurationRefreshOptions(); configure?.Invoke(refreshOptions); - if (!refreshOptions.RefreshRegistrations.Any()) + bool isRegisterCalled = refreshOptions.RefreshRegistrations.Any(); + RegisterAllEnabled = refreshOptions.RegisterAllEnabled; + + if (!isRegisterCalled && !RegisterAllEnabled) + { + throw new InvalidOperationException($"{nameof(ConfigureRefresh)}() must call either {nameof(AzureAppConfigurationRefreshOptions.Register)}()" + + $" or {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)}()"); + } + + // Check if both register methods are called at any point + if (RegisterAllEnabled && (_individualKvWatchers.Any() || isRegisterCalled)) { - throw new ArgumentException($"{nameof(ConfigureRefresh)}() must have at least one key-value registered for refresh."); + throw new InvalidOperationException($"Cannot call both {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} and " + + $"{nameof(AzureAppConfigurationRefreshOptions.Register)}."); } - foreach (var item in refreshOptions.RefreshRegistrations) + if (RegisterAllEnabled) + { + KvCollectionRefreshInterval = refreshOptions.RefreshInterval; + } + else { - item.RefreshInterval = refreshOptions.RefreshInterval; - _changeWatchers.Add(item); + foreach (KeyValueWatcher item in refreshOptions.RefreshRegistrations) + { + item.RefreshInterval = refreshOptions.RefreshInterval; + _individualKvWatchers.Add(item); + } } return this; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index b5dd42f18..ebe8992c1 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -4,7 +4,6 @@ using Azure; using Azure.Data.AppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; using Microsoft.Extensions.Logging; using System; @@ -32,9 +31,13 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private Uri _lastSuccessfulEndpoint; private AzureAppConfigurationOptions _options; private Dictionary _mappedData; - private Dictionary _watchedSettings = new Dictionary(); + private Dictionary _watchedIndividualKvs = new Dictionary(); + private HashSet _ffKeys = new HashSet(); + private Dictionary> _kvEtags = new Dictionary>(); + private Dictionary> _ffEtags = new Dictionary>(); private RequestTracingOptions _requestTracingOptions; private Dictionary _configClientBackoffs = new Dictionary(); + private DateTimeOffset _nextCollectionRefreshTime; private readonly TimeSpan MinRefreshInterval; @@ -108,11 +111,18 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan _options = options ?? throw new ArgumentNullException(nameof(options)); _optional = optional; - IEnumerable watchers = options.ChangeWatchers.Union(options.MultiKeyWatchers); + IEnumerable watchers = options.IndividualKvWatchers.Union(options.FeatureFlagWatchers); - if (watchers.Any()) + bool hasWatchers = watchers.Any(); + TimeSpan minWatcherRefreshInterval = hasWatchers ? watchers.Min(w => w.RefreshInterval) : TimeSpan.MaxValue; + + if (options.RegisterAllEnabled) + { + MinRefreshInterval = TimeSpan.FromTicks(Math.Min(minWatcherRefreshInterval.Ticks, options.KvCollectionRefreshInterval.Ticks)); + } + else if (hasWatchers) { - MinRefreshInterval = watchers.Min(w => w.RefreshInterval); + MinRefreshInterval = minWatcherRefreshInterval; } else { @@ -194,13 +204,15 @@ public async Task RefreshAsync(CancellationToken cancellationToken) EnsureAssemblyInspected(); var utcNow = DateTimeOffset.UtcNow; - IEnumerable refreshableWatchers = _options.ChangeWatchers.Where(changeWatcher => utcNow >= changeWatcher.NextRefreshTime); - IEnumerable refreshableMultiKeyWatchers = _options.MultiKeyWatchers.Where(changeWatcher => utcNow >= changeWatcher.NextRefreshTime); + IEnumerable refreshableIndividualKvWatchers = _options.IndividualKvWatchers.Where(kvWatcher => utcNow >= kvWatcher.NextRefreshTime); + IEnumerable refreshableFfWatchers = _options.FeatureFlagWatchers.Where(ffWatcher => utcNow >= ffWatcher.NextRefreshTime); + bool isRefreshDue = utcNow >= _nextCollectionRefreshTime; // Skip refresh if mappedData is loaded, but none of the watchers or adapters are refreshable. if (_mappedData != null && - !refreshableWatchers.Any() && - !refreshableMultiKeyWatchers.Any() && + !refreshableIndividualKvWatchers.Any() && + !refreshableFfWatchers.Any() && + !isRefreshDue && !_options.Adapters.Any(adapter => adapter.NeedsRefresh())) { return; @@ -249,179 +261,166 @@ public async Task RefreshAsync(CancellationToken cancellationToken) // // Avoid instance state modification - Dictionary watchedSettings = null; + Dictionary> kvEtags = null; + Dictionary> ffEtags = null; + HashSet ffKeys = null; + Dictionary watchedIndividualKvs = null; List keyValueChanges = null; - List changedKeyValuesCollection = null; Dictionary data = null; + Dictionary ffCollectionData = null; + bool ffCollectionUpdated = false; bool refreshAll = false; StringBuilder logInfoBuilder = new StringBuilder(); StringBuilder logDebugBuilder = new StringBuilder(); await ExecuteWithFailOverPolicyAsync(clients, async (client) => - { - data = null; - watchedSettings = null; - keyValueChanges = new List(); - changedKeyValuesCollection = null; - refreshAll = false; - Uri endpoint = _configClientManager.GetEndpointForClient(client); - logDebugBuilder.Clear(); - logInfoBuilder.Clear(); - - foreach (KeyValueWatcher changeWatcher in refreshableWatchers) - { - string watchedKey = changeWatcher.Key; - string watchedLabel = changeWatcher.Label; - - KeyValueIdentifier watchedKeyLabel = new KeyValueIdentifier(watchedKey, watchedLabel); - - KeyValueChange change = default; - - // - // Find if there is a change associated with watcher - if (_watchedSettings.TryGetValue(watchedKeyLabel, out ConfigurationSetting watchedKv)) - { - await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, - async () => change = await client.GetKeyValueChange(watchedKv, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); - } - else - { - // Load the key-value in case the previous load attempts had failed - - try - { - await CallWithRequestTracing( - async () => watchedKv = await client.GetConfigurationSettingAsync(watchedKey, watchedLabel, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); - } - catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound) - { - watchedKv = null; - } - - if (watchedKv != null) - { - change = new KeyValueChange() - { - Key = watchedKv.Key, - Label = watchedKv.Label.NormalizeNull(), - Current = watchedKv, - ChangeType = KeyValueChangeType.Modified - }; - } - } - - // Check if a change has been detected in the key-value registered for refresh - if (change.ChangeType != KeyValueChangeType.None) - { - logDebugBuilder.AppendLine(LogHelper.BuildKeyValueReadMessage(change.ChangeType, change.Key, change.Label, endpoint.ToString())); - logInfoBuilder.AppendLine(LogHelper.BuildKeyValueSettingUpdatedMessage(change.Key)); - keyValueChanges.Add(change); - - if (changeWatcher.RefreshAll) - { - refreshAll = true; - break; - } - } - else - { - logDebugBuilder.AppendLine(LogHelper.BuildKeyValueReadMessage(change.ChangeType, change.Key, change.Label, endpoint.ToString())); - } - } + { + kvEtags = null; + ffEtags = null; + ffKeys = null; + watchedIndividualKvs = null; + keyValueChanges = new List(); + data = null; + ffCollectionData = null; + ffCollectionUpdated = false; + refreshAll = false; + logDebugBuilder.Clear(); + logInfoBuilder.Clear(); + Uri endpoint = _configClientManager.GetEndpointForClient(client); - if (refreshAll) + if (_options.RegisterAllEnabled) + { + // Get key value collection changes if RegisterAll was called + if (isRefreshDue) { - // Trigger a single load-all operation if a change was detected in one or more key-values with refreshAll: true - data = await LoadSelectedKeyValues(client, cancellationToken).ConfigureAwait(false); - watchedSettings = await LoadKeyValuesRegisteredForRefresh(client, data, cancellationToken).ConfigureAwait(false); - watchedSettings = UpdateWatchedKeyValueCollections(watchedSettings, data); - logInfoBuilder.AppendLine(LogHelper.BuildConfigurationUpdatedMessage()); - return; + refreshAll = await HaveCollectionsChanged( + _options.Selectors.Where(selector => !selector.IsFeatureFlagSelector), + _kvEtags, + client, + cancellationToken).ConfigureAwait(false); } + } + else + { + refreshAll = await RefreshIndividualKvWatchers( + client, + keyValueChanges, + refreshableIndividualKvWatchers, + endpoint, + logDebugBuilder, + logInfoBuilder, + cancellationToken).ConfigureAwait(false); + } - changedKeyValuesCollection = await GetRefreshedKeyValueCollections(refreshableMultiKeyWatchers, client, logDebugBuilder, logInfoBuilder, endpoint, cancellationToken).ConfigureAwait(false); + if (refreshAll) + { + // Trigger a single load-all operation if a change was detected in one or more key-values with refreshAll: true, + // or if any key-value collection change was detected. + kvEtags = new Dictionary>(); + ffEtags = new Dictionary>(); + ffKeys = new HashSet(); + + data = await LoadSelected(client, kvEtags, ffEtags, _options.Selectors, ffKeys, cancellationToken).ConfigureAwait(false); + watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh(client, data, cancellationToken).ConfigureAwait(false); + logInfoBuilder.AppendLine(LogHelper.BuildConfigurationUpdatedMessage()); + return; + } - if (!changedKeyValuesCollection.Any()) + // Get feature flag changes + ffCollectionUpdated = await HaveCollectionsChanged( + refreshableFfWatchers.Select(watcher => new KeyValueSelector { - logDebugBuilder.AppendLine(LogHelper.BuildFeatureFlagsUnchangedMessage(endpoint.ToString())); - } - }, - cancellationToken) - .ConfigureAwait(false); + KeyFilter = watcher.Key, + LabelFilter = watcher.Label, + IsFeatureFlagSelector = true + }), + _ffEtags, + client, + cancellationToken).ConfigureAwait(false); + + if (ffCollectionUpdated) + { + ffEtags = new Dictionary>(); + ffKeys = new HashSet(); + + ffCollectionData = await LoadSelected( + client, + new Dictionary>(), + ffEtags, + _options.Selectors.Where(selector => selector.IsFeatureFlagSelector), + ffKeys, + cancellationToken).ConfigureAwait(false); + + logInfoBuilder.Append(LogHelper.BuildFeatureFlagsUpdatedMessage()); + } + else + { + logDebugBuilder.AppendLine(LogHelper.BuildFeatureFlagsUnchangedMessage(endpoint.ToString())); + } + }, + cancellationToken) + .ConfigureAwait(false); - if (!refreshAll) + if (refreshAll) { - watchedSettings = new Dictionary(_watchedSettings); + _mappedData = await MapConfigurationSettings(data).ConfigureAwait(false); - foreach (KeyValueWatcher changeWatcher in refreshableWatchers.Concat(refreshableMultiKeyWatchers)) + // Invalidate all the cached KeyVault secrets + foreach (IKeyValueAdapter adapter in _options.Adapters) { - UpdateNextRefreshTime(changeWatcher); + adapter.OnChangeDetected(); } - foreach (KeyValueChange change in keyValueChanges.Concat(changedKeyValuesCollection)) + // Update the next refresh time for all refresh registered settings and feature flags + foreach (KeyValueWatcher changeWatcher in _options.IndividualKvWatchers.Concat(_options.FeatureFlagWatchers)) { - KeyValueIdentifier changeIdentifier = new KeyValueIdentifier(change.Key, change.Label); - if (change.ChangeType == KeyValueChangeType.Modified) - { - ConfigurationSetting setting = change.Current; - ConfigurationSetting settingCopy = new ConfigurationSetting(setting.Key, setting.Value, setting.Label, setting.ETag); - watchedSettings[changeIdentifier] = settingCopy; + UpdateNextRefreshTime(changeWatcher); + } + } + else + { + watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); - foreach (Func> func in _options.Mappers) - { - setting = await func(setting).ConfigureAwait(false); - } + await ProcessKeyValueChangesAsync(keyValueChanges, _mappedData, watchedIndividualKvs).ConfigureAwait(false); - if (setting == null) - { - _mappedData.Remove(change.Key); - } - else - { - _mappedData[change.Key] = setting; - } - } - else if (change.ChangeType == KeyValueChangeType.Deleted) + if (ffCollectionUpdated) + { + // Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously + foreach (string key in _ffKeys.Except(ffKeys)) { - _mappedData.Remove(change.Key); - watchedSettings.Remove(changeIdentifier); + _mappedData.Remove(key); } - // Invalidate the cached Key Vault secret (if any) for this ConfigurationSetting - foreach (IKeyValueAdapter adapter in _options.Adapters) + Dictionary mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false); + + foreach (KeyValuePair kvp in mappedFfData) { - // If the current setting is null, try to pass the previous setting instead - if (change.Current != null) - { - adapter.OnChangeDetected(change.Current); - } - else if (change.Previous != null) - { - adapter.OnChangeDetected(change.Previous); - } + _mappedData[kvp.Key] = kvp.Value; } } - } - else - { - _mappedData = await MapConfigurationSettings(data).ConfigureAwait(false); - - // Invalidate all the cached KeyVault secrets - foreach (IKeyValueAdapter adapter in _options.Adapters) - { - adapter.OnChangeDetected(); - } - // Update the next refresh time for all refresh registered settings and feature flags - foreach (KeyValueWatcher changeWatcher in _options.ChangeWatchers.Concat(_options.MultiKeyWatchers)) + // + // update the next refresh time for all refresh registered settings and feature flags + foreach (KeyValueWatcher changeWatcher in refreshableIndividualKvWatchers.Concat(refreshableFfWatchers)) { UpdateNextRefreshTime(changeWatcher); } } - if (_options.Adapters.Any(adapter => adapter.NeedsRefresh()) || changedKeyValuesCollection?.Any() == true || keyValueChanges.Any()) + if (isRefreshDue) { - _watchedSettings = watchedSettings; + _nextCollectionRefreshTime = DateTimeOffset.UtcNow.Add(_options.KvCollectionRefreshInterval); + } + + if (_options.Adapters.Any(adapter => adapter.NeedsRefresh()) || keyValueChanges.Any() || refreshAll || ffCollectionUpdated) + { + _watchedIndividualKvs = watchedIndividualKvs ?? _watchedIndividualKvs; + + _ffEtags = ffEtags ?? _ffEtags; + + _kvEtags = kvEtags ?? _kvEtags; + + _ffKeys = ffKeys ?? _ffKeys; if (logDebugBuilder.Length > 0) { @@ -432,6 +431,7 @@ await CallWithRequestTracing( { _logger.LogInformation(logInfoBuilder.ToString().Trim()); } + // PrepareData makes calls to KeyVault and may throw exceptions. But, we still update watchers before // SetData because repeating appconfig calls (by not updating watchers) won't help anything for keyvault calls. // As long as adapter.NeedsRefresh is true, we will attempt to update keyvault again the next time RefreshAsync is called. @@ -555,14 +555,21 @@ private void SetDirty(TimeSpan? maxDelay) { DateTimeOffset nextRefreshTime = AddRandomDelay(DateTimeOffset.UtcNow, maxDelay ?? DefaultMaxSetDirtyDelay); - foreach (KeyValueWatcher changeWatcher in _options.ChangeWatchers) + if (_options.RegisterAllEnabled) { - changeWatcher.NextRefreshTime = nextRefreshTime; + _nextCollectionRefreshTime = nextRefreshTime; + } + else + { + foreach (KeyValueWatcher kvWatcher in _options.IndividualKvWatchers) + { + kvWatcher.NextRefreshTime = nextRefreshTime; + } } - foreach (KeyValueWatcher changeWatcher in _options.MultiKeyWatchers) + foreach (KeyValueWatcher featureFlagWatcher in _options.FeatureFlagWatchers) { - changeWatcher.NextRefreshTime = nextRefreshTime; + featureFlagWatcher.NextRefreshTime = nextRefreshTime; } } @@ -707,34 +714,44 @@ private async Task TryInitializeAsync(IEnumerable cli private async Task InitializeAsync(IEnumerable clients, CancellationToken cancellationToken = default) { Dictionary data = null; - Dictionary watchedSettings = null; + Dictionary> kvEtags = new Dictionary>(); + Dictionary> ffEtags = new Dictionary>(); + Dictionary watchedIndividualKvs = null; + HashSet ffKeys = new HashSet(); await ExecuteWithFailOverPolicyAsync( clients, async (client) => { - data = await LoadSelectedKeyValues( + data = await LoadSelected( client, + kvEtags, + ffEtags, + _options.Selectors, + ffKeys, cancellationToken) .ConfigureAwait(false); - watchedSettings = await LoadKeyValuesRegisteredForRefresh( + watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh( client, data, cancellationToken) .ConfigureAwait(false); - - watchedSettings = UpdateWatchedKeyValueCollections(watchedSettings, data); }, cancellationToken) .ConfigureAwait(false); // Update the next refresh time for all refresh registered settings and feature flags - foreach (KeyValueWatcher changeWatcher in _options.ChangeWatchers.Concat(_options.MultiKeyWatchers)) + foreach (KeyValueWatcher changeWatcher in _options.IndividualKvWatchers.Concat(_options.FeatureFlagWatchers)) { UpdateNextRefreshTime(changeWatcher); } + if (_options.RegisterAllEnabled) + { + _nextCollectionRefreshTime = DateTimeOffset.UtcNow.Add(_options.KvCollectionRefreshInterval); + } + if (data != null) { // Invalidate all the cached KeyVault secrets @@ -744,51 +761,71 @@ await ExecuteWithFailOverPolicyAsync( } Dictionary mappedData = await MapConfigurationSettings(data).ConfigureAwait(false); + SetData(await PrepareData(mappedData, cancellationToken).ConfigureAwait(false)); - _watchedSettings = watchedSettings; + _mappedData = mappedData; + _kvEtags = kvEtags; + _ffEtags = ffEtags; + _watchedIndividualKvs = watchedIndividualKvs; + _ffKeys = ffKeys; } } - private async Task> LoadSelectedKeyValues(ConfigurationClient client, CancellationToken cancellationToken) + private async Task> LoadSelected( + ConfigurationClient client, + Dictionary> kvEtags, + Dictionary> ffEtags, + IEnumerable selectors, + HashSet ffKeys, + CancellationToken cancellationToken) { - var serverData = new Dictionary(StringComparer.OrdinalIgnoreCase); + Dictionary data = new Dictionary(); - // Use default query if there are no key-values specified for use other than the feature flags - bool useDefaultQuery = !_options.KeyValueSelectors.Any(selector => selector.KeyFilter == null || - !selector.KeyFilter.StartsWith(FeatureManagementConstants.FeatureFlagMarker)); - - if (useDefaultQuery) + foreach (KeyValueSelector loadOption in selectors) { - // Load all key-values with the null label. - var selector = new SettingSelector - { - KeyFilter = KeyFilter.Any, - LabelFilter = LabelFilter.Null - }; - - await CallWithRequestTracing(async () => + if (string.IsNullOrEmpty(loadOption.SnapshotName)) { - await foreach (ConfigurationSetting setting in client.GetConfigurationSettingsAsync(selector, cancellationToken).ConfigureAwait(false)) + var selector = new SettingSelector() { - serverData[setting.Key] = setting; - } - }).ConfigureAwait(false); - } + KeyFilter = loadOption.KeyFilter, + LabelFilter = loadOption.LabelFilter + }; - foreach (KeyValueSelector loadOption in _options.KeyValueSelectors) - { - IAsyncEnumerable settingsEnumerable; + var matchConditions = new List(); - if (string.IsNullOrEmpty(loadOption.SnapshotName)) - { - settingsEnumerable = client.GetConfigurationSettingsAsync( - new SettingSelector + await CallWithRequestTracing(async () => + { + AsyncPageable pageableSettings = client.GetConfigurationSettingsAsync(selector, cancellationToken); + + await foreach (Page page in pageableSettings.AsPages(_options.ConfigurationSettingPageIterator).ConfigureAwait(false)) { - KeyFilter = loadOption.KeyFilter, - LabelFilter = loadOption.LabelFilter - }, - cancellationToken); + using Response response = page.GetRawResponse(); + + ETag serverEtag = (ETag)response.Headers.ETag; + + foreach (ConfigurationSetting setting in page.Values) + { + data[setting.Key] = setting; + + if (loadOption.IsFeatureFlagSelector) + { + ffKeys.Add(setting.Key); + } + } + + matchConditions.Add(new MatchConditions { IfNoneMatch = serverEtag }); + } + }).ConfigureAwait(false); + + if (loadOption.IsFeatureFlagSelector) + { + ffEtags[loadOption] = matchConditions; + } + else + { + kvEtags[loadOption] = matchConditions; + } } else { @@ -808,38 +845,42 @@ await CallWithRequestTracing(async () => throw new InvalidOperationException($"{nameof(snapshot.SnapshotComposition)} for the selected snapshot with name '{snapshot.Name}' must be 'key', found '{snapshot.SnapshotComposition}'."); } - settingsEnumerable = client.GetConfigurationSettingsForSnapshotAsync( + IAsyncEnumerable settingsEnumerable = client.GetConfigurationSettingsForSnapshotAsync( loadOption.SnapshotName, cancellationToken); - } - await CallWithRequestTracing(async () => - { - await foreach (ConfigurationSetting setting in settingsEnumerable.ConfigureAwait(false)) + await CallWithRequestTracing(async () => { - serverData[setting.Key] = setting; - } - }).ConfigureAwait(false); + await foreach (ConfigurationSetting setting in settingsEnumerable.ConfigureAwait(false)) + { + data[setting.Key] = setting; + } + }).ConfigureAwait(false); + } } - return serverData; + return data; } - private async Task> LoadKeyValuesRegisteredForRefresh(ConfigurationClient client, IDictionary existingSettings, CancellationToken cancellationToken) + private async Task> LoadKeyValuesRegisteredForRefresh( + ConfigurationClient client, + IDictionary existingSettings, + CancellationToken cancellationToken) { - Dictionary watchedSettings = new Dictionary(); + var watchedIndividualKvs = new Dictionary(); - foreach (KeyValueWatcher changeWatcher in _options.ChangeWatchers) + foreach (KeyValueWatcher kvWatcher in _options.IndividualKvWatchers) { - string watchedKey = changeWatcher.Key; - string watchedLabel = changeWatcher.Label; + string watchedKey = kvWatcher.Key; + string watchedLabel = kvWatcher.Label; + KeyValueIdentifier watchedKeyLabel = new KeyValueIdentifier(watchedKey, watchedLabel); // Skip the loading for the key-value in case it has already been loaded if (existingSettings.TryGetValue(watchedKey, out ConfigurationSetting loadedKv) && watchedKeyLabel.Equals(new KeyValueIdentifier(loadedKv.Key, loadedKv.Label))) { - watchedSettings[watchedKeyLabel] = new ConfigurationSetting(loadedKv.Key, loadedKv.Value, loadedKv.Label, loadedKv.ETag); + watchedIndividualKvs[watchedKeyLabel] = new ConfigurationSetting(loadedKv.Key, loadedKv.Value, loadedKv.Label, loadedKv.ETag); continue; } @@ -857,61 +898,84 @@ private async Task> LoadKey // If the key-value was found, store it for updating the settings if (watchedKv != null) { - watchedSettings[watchedKeyLabel] = new ConfigurationSetting(watchedKv.Key, watchedKv.Value, watchedKv.Label, watchedKv.ETag); + watchedIndividualKvs[watchedKeyLabel] = new ConfigurationSetting(watchedKv.Key, watchedKv.Value, watchedKv.Label, watchedKv.ETag); existingSettings[watchedKey] = watchedKv; } } - return watchedSettings; + return watchedIndividualKvs; } - private Dictionary UpdateWatchedKeyValueCollections(Dictionary watchedSettings, IDictionary existingSettings) + private async Task RefreshIndividualKvWatchers( + ConfigurationClient client, + List keyValueChanges, + IEnumerable refreshableIndividualKvWatchers, + Uri endpoint, + StringBuilder logDebugBuilder, + StringBuilder logInfoBuilder, + CancellationToken cancellationToken) { - foreach (KeyValueWatcher changeWatcher in _options.MultiKeyWatchers) + foreach (KeyValueWatcher kvWatcher in refreshableIndividualKvWatchers) { - IEnumerable currentKeyValues = GetCurrentKeyValueCollection(changeWatcher.Key, changeWatcher.Label, existingSettings.Values); + string watchedKey = kvWatcher.Key; + string watchedLabel = kvWatcher.Label; - foreach (ConfigurationSetting setting in currentKeyValues) + KeyValueIdentifier watchedKeyLabel = new KeyValueIdentifier(watchedKey, watchedLabel); + + KeyValueChange change = default; + + // + // Find if there is a change associated with watcher + if (_watchedIndividualKvs.TryGetValue(watchedKeyLabel, out ConfigurationSetting watchedKv)) { - watchedSettings[new KeyValueIdentifier(setting.Key, setting.Label)] = new ConfigurationSetting(setting.Key, setting.Value, setting.Label, setting.ETag); + await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, + async () => change = await client.GetKeyValueChange(watchedKv, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); } - } + else + { + // Load the key-value in case the previous load attempts had failed - return watchedSettings; - } + try + { + await CallWithRequestTracing( + async () => watchedKv = await client.GetConfigurationSettingAsync(watchedKey, watchedLabel, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + } + catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound) + { + watchedKv = null; + } - private async Task> GetRefreshedKeyValueCollections( - IEnumerable multiKeyWatchers, - ConfigurationClient client, - StringBuilder logDebugBuilder, - StringBuilder logInfoBuilder, - Uri endpoint, - CancellationToken cancellationToken) - { - var keyValueChanges = new List(); + if (watchedKv != null) + { + change = new KeyValueChange() + { + Key = watchedKv.Key, + Label = watchedKv.Label.NormalizeNull(), + Current = watchedKv, + ChangeType = KeyValueChangeType.Modified + }; + } + } - foreach (KeyValueWatcher changeWatcher in multiKeyWatchers) - { - IEnumerable currentKeyValues = GetCurrentKeyValueCollection(changeWatcher.Key, changeWatcher.Label, _watchedSettings.Values); + // Check if a change has been detected in the key-value registered for refresh + if (change.ChangeType != KeyValueChangeType.None) + { + logDebugBuilder.AppendLine(LogHelper.BuildKeyValueReadMessage(change.ChangeType, change.Key, change.Label, endpoint.ToString())); + logInfoBuilder.AppendLine(LogHelper.BuildKeyValueSettingUpdatedMessage(change.Key)); + keyValueChanges.Add(change); - keyValueChanges.AddRange( - await client.GetKeyValueChangeCollection( - currentKeyValues, - new GetKeyValueChangeCollectionOptions - { - KeyFilter = changeWatcher.Key, - Label = changeWatcher.Label.NormalizeNull(), - RequestTracingEnabled = _requestTracingEnabled, - RequestTracingOptions = _requestTracingOptions - }, - logDebugBuilder, - logInfoBuilder, - endpoint, - cancellationToken) - .ConfigureAwait(false)); + if (kvWatcher.RefreshAll) + { + return true; + } + } + else + { + logDebugBuilder.AppendLine(LogHelper.BuildKeyValueReadMessage(change.ChangeType, change.Key, change.Label, endpoint.ToString())); + } } - return keyValueChanges; + return false; } private void SetData(IDictionary data) @@ -1179,30 +1243,6 @@ private async Task> MapConfigurationSet return mappedData; } - private IEnumerable GetCurrentKeyValueCollection(string key, string label, IEnumerable existingSettings) - { - IEnumerable currentKeyValues; - - if (key.EndsWith("*")) - { - // Get current application settings starting with changeWatcher.Key, excluding the last * character - string keyPrefix = key.Substring(0, key.Length - 1); - currentKeyValues = existingSettings.Where(kv => - { - return kv.Key.StartsWith(keyPrefix) && kv.Label == label.NormalizeNull(); - }); - } - else - { - currentKeyValues = existingSettings.Where(kv => - { - return kv.Key.Equals(key) && kv.Label == label.NormalizeNull(); - }); - } - - return currentKeyValues; - } - private void EnsureAssemblyInspected() { if (!_isAssemblyInspected) @@ -1248,6 +1288,88 @@ private void UpdateClientBackoffStatus(Uri endpoint, bool successful) _configClientBackoffs[endpoint] = clientBackoffStatus; } + private async Task HaveCollectionsChanged( + IEnumerable selectors, + Dictionary> pageEtags, + ConfigurationClient client, + CancellationToken cancellationToken) + { + bool haveCollectionsChanged = false; + + foreach (KeyValueSelector selector in selectors) + { + if (pageEtags.TryGetValue(selector, out IEnumerable matchConditions)) + { + await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, + async () => haveCollectionsChanged = await client.HaveCollectionsChanged( + selector, + matchConditions, + _options.ConfigurationSettingPageIterator, + cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + } + + if (haveCollectionsChanged) + { + return true; + } + } + + return haveCollectionsChanged; + } + + private async Task ProcessKeyValueChangesAsync( + IEnumerable keyValueChanges, + Dictionary mappedData, + Dictionary watchedIndividualKvs) + { + foreach (KeyValueChange change in keyValueChanges) + { + KeyValueIdentifier changeIdentifier = new KeyValueIdentifier(change.Key, change.Label); + + if (change.ChangeType == KeyValueChangeType.Modified) + { + ConfigurationSetting setting = change.Current; + ConfigurationSetting settingCopy = new ConfigurationSetting(setting.Key, setting.Value, setting.Label, setting.ETag); + + watchedIndividualKvs[changeIdentifier] = settingCopy; + + foreach (Func> func in _options.Mappers) + { + setting = await func(setting).ConfigureAwait(false); + } + + if (setting == null) + { + mappedData.Remove(change.Key); + } + else + { + mappedData[change.Key] = setting; + } + } + else if (change.ChangeType == KeyValueChangeType.Deleted) + { + mappedData.Remove(change.Key); + + watchedIndividualKvs.Remove(changeIdentifier); + } + + // Invalidate the cached Key Vault secret (if any) for this ConfigurationSetting + foreach (IKeyValueAdapter adapter in _options.Adapters) + { + // If the current setting is null, try to pass the previous setting instead + if (change.Current != null) + { + adapter.OnChangeDetected(change.Current); + } + else if (change.Previous != null) + { + adapter.OnChangeDetected(change.Previous); + } + } + } + } + public void Dispose() { (_configClientManager as ConfigurationClientManager)?.Dispose(); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs index f3fb6c4a1..cf2847a82 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs @@ -14,6 +14,7 @@ public class AzureAppConfigurationRefreshOptions { internal TimeSpan RefreshInterval { get; private set; } = RefreshConstants.DefaultRefreshInterval; internal ISet RefreshRegistrations = new HashSet(); + internal bool RegisterAllEnabled { get; private set; } /// /// Register the specified individual key-value to be refreshed when the configuration provider's triggers a refresh. @@ -50,6 +51,17 @@ public AzureAppConfigurationRefreshOptions Register(string key, string label = L return this; } + /// + /// Register all key-values loaded outside of to be refreshed when the configuration provider's triggers a refresh. + /// The instance can be obtained by calling . + /// + public AzureAppConfigurationRefreshOptions RegisterAll() + { + RegisterAllEnabled = true; + + return this; + } + /// /// Sets the cache expiration time for the key-values registered for refresh. Default value is 30 seconds. Must be greater than 1 second. /// Any refresh operation triggered using will not update the value for a key until the cached value for that key has expired. diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs new file mode 100644 index 000000000..aba7684b3 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs @@ -0,0 +1,33 @@ +using Azure.Data.AppConfiguration; +using Azure; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + static class ConfigurationSettingPageExtensions + { + public static IAsyncEnumerable> AsPages(this AsyncPageable pageable, IConfigurationSettingPageIterator pageIterator) + { + // + // Allow custom iteration + if (pageIterator != null) + { + return pageIterator.IteratePages(pageable); + } + + return pageable.AsPages(); + } + + public static IAsyncEnumerable> AsPages(this AsyncPageable pageable, IConfigurationSettingPageIterator pageIterator, IEnumerable matchConditions) + { + // + // Allow custom iteration + if (pageIterator != null) + { + return pageIterator.IteratePages(pageable, matchConditions); + } + + return pageable.AsPages(matchConditions); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs index 86576a483..3bdcaecd5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs @@ -20,14 +20,15 @@ internal class LoggingConstants // Successful update, debug log level public const string RefreshKeyValueRead = "Key-value read from App Configuration."; public const string RefreshKeyVaultSecretRead = "Secret read from Key Vault for key-value."; - public const string RefreshFeatureFlagRead = "Feature flag read from App Configuration."; public const string RefreshFeatureFlagsUnchanged = "Feature flags read from App Configuration. Change:'None'"; + public const string RefreshSelectedKeyValueCollectionsUnchanged = "Selected key-value collections read from App Configuration. Change:'None'"; // Successful update, information log level public const string RefreshConfigurationUpdatedSuccess = "Configuration reloaded."; public const string RefreshKeyValueSettingUpdated = "Setting updated."; public const string RefreshKeyVaultSettingUpdated = "Setting updated from Key Vault."; - public const string RefreshFeatureFlagUpdated = "Feature flag updated."; + public const string RefreshFeatureFlagsUpdated = "Feature flags reloaded."; + public const string RefreshSelectedKeyValuesAndFeatureFlagsUpdated = "Selected key-value collections and feature flags reloaded."; // Other public const string RefreshSkippedNoClientAvailable = "Refresh skipped because no endpoint is accessible."; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index d479ad6bf..677122310 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -3,12 +3,10 @@ // using Azure; using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; using System; using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Text; using System.Threading; using System.Threading.Tasks; @@ -65,131 +63,50 @@ public static async Task GetKeyValueChange(this ConfigurationCli }; } - public static async Task> GetKeyValueChangeCollection( - this ConfigurationClient client, - IEnumerable keyValues, - GetKeyValueChangeCollectionOptions options, - StringBuilder logDebugBuilder, - StringBuilder logInfoBuilder, - Uri endpoint, - CancellationToken cancellationToken) + public static async Task HaveCollectionsChanged(this ConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable matchConditions, IConfigurationSettingPageIterator pageIterator, CancellationToken cancellationToken) { - if (options == null) + if (matchConditions == null) { - throw new ArgumentNullException(nameof(options)); + throw new ArgumentNullException(nameof(matchConditions)); } - if (keyValues == null) + if (keyValueSelector == null) { - keyValues = Enumerable.Empty(); + throw new ArgumentNullException(nameof(keyValueSelector)); } - if (options.KeyFilter == null) + if (keyValueSelector.SnapshotName != null) { - options.KeyFilter = string.Empty; + throw new ArgumentException("Cannot check snapshot for changes.", $"{nameof(keyValueSelector)}.{nameof(keyValueSelector.SnapshotName)}"); } - if (keyValues.Any(k => string.IsNullOrEmpty(k.Key))) + SettingSelector selector = new SettingSelector { - throw new ArgumentNullException($"{nameof(keyValues)}[].{nameof(ConfigurationSetting.Key)}"); - } - - if (keyValues.Any(k => !string.Equals(k.Label.NormalizeNull(), options.Label.NormalizeNull()))) - { - throw new ArgumentException("All key-values registered for refresh must use the same label.", $"{nameof(keyValues)}[].{nameof(ConfigurationSetting.Label)}"); - } - - if (keyValues.Any(k => k.Label != null && k.Label.Contains("*"))) - { - throw new ArgumentException("The label filter cannot contain '*'", $"{nameof(options)}.{nameof(options.Label)}"); - } - - var hasKeyValueCollectionChanged = false; - var selector = new SettingSelector - { - KeyFilter = options.KeyFilter, - LabelFilter = string.IsNullOrEmpty(options.Label) ? LabelFilter.Null : options.Label, - Fields = SettingFields.ETag | SettingFields.Key + KeyFilter = keyValueSelector.KeyFilter, + LabelFilter = keyValueSelector.LabelFilter }; - // Dictionary of eTags that we write to and use for comparison - var eTagMap = keyValues.ToDictionary(kv => kv.Key, kv => kv.ETag); + AsyncPageable pageable = client.GetConfigurationSettingsAsync(selector, cancellationToken); - // Fetch e-tags for prefixed key-values that can be used to detect changes - await TracingUtils.CallWithRequestTracing(options.RequestTracingEnabled, RequestType.Watch, options.RequestTracingOptions, - async () => - { - await foreach (ConfigurationSetting setting in client.GetConfigurationSettingsAsync(selector, cancellationToken).ConfigureAwait(false)) - { - if (!eTagMap.TryGetValue(setting.Key, out ETag etag) || !etag.Equals(setting.ETag)) - { - hasKeyValueCollectionChanged = true; - break; - } + using IEnumerator existingMatchConditionsEnumerator = matchConditions.GetEnumerator(); - eTagMap.Remove(setting.Key); - } - }).ConfigureAwait(false); - - // Check for any deletions - if (eTagMap.Any()) - { - hasKeyValueCollectionChanged = true; - } - - var changes = new List(); - - // If changes have been observed, refresh prefixed key-values - if (hasKeyValueCollectionChanged) + await foreach (Page page in pageable.AsPages(pageIterator, matchConditions).ConfigureAwait(false)) { - selector = new SettingSelector - { - KeyFilter = options.KeyFilter, - LabelFilter = string.IsNullOrEmpty(options.Label) ? LabelFilter.Null : options.Label - }; + using Response response = page.GetRawResponse(); - eTagMap = keyValues.ToDictionary(kv => kv.Key, kv => kv.ETag); - await TracingUtils.CallWithRequestTracing(options.RequestTracingEnabled, RequestType.Watch, options.RequestTracingOptions, - async () => - { - await foreach (ConfigurationSetting setting in client.GetConfigurationSettingsAsync(selector, cancellationToken).ConfigureAwait(false)) - { - if (!eTagMap.TryGetValue(setting.Key, out ETag etag) || !etag.Equals(setting.ETag)) - { - changes.Add(new KeyValueChange - { - ChangeType = KeyValueChangeType.Modified, - Key = setting.Key, - Label = options.Label.NormalizeNull(), - Previous = null, - Current = setting - }); - string key = setting.Key.Substring(FeatureManagementConstants.FeatureFlagMarker.Length); - logDebugBuilder.AppendLine(LogHelper.BuildFeatureFlagReadMessage(key, options.Label.NormalizeNull(), endpoint.ToString())); - logInfoBuilder.AppendLine(LogHelper.BuildFeatureFlagUpdatedMessage(key)); - } + ETag serverEtag = (ETag)response.Headers.ETag; - eTagMap.Remove(setting.Key); - } - }).ConfigureAwait(false); - - foreach (var kvp in eTagMap) + // Return true if the lists of etags are different + if ((!existingMatchConditionsEnumerator.MoveNext() || + !existingMatchConditionsEnumerator.Current.IfNoneMatch.Equals(serverEtag)) && + response.Status == (int)HttpStatusCode.OK) { - changes.Add(new KeyValueChange - { - ChangeType = KeyValueChangeType.Deleted, - Key = kvp.Key, - Label = options.Label.NormalizeNull(), - Previous = null, - Current = null - }); - string key = kvp.Key.Substring(FeatureManagementConstants.FeatureFlagMarker.Length); - logDebugBuilder.AppendLine(LogHelper.BuildFeatureFlagReadMessage(key, options.Label.NormalizeNull(), endpoint.ToString())); - logInfoBuilder.AppendLine(LogHelper.BuildFeatureFlagUpdatedMessage(key)); + return true; } } - return changes; + // Need to check if pages were deleted and no change was found within the new shorter list of match conditions + return existingMatchConditionsEnumerator.MoveNext(); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs index 8b2c488d0..615720305 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs @@ -5,18 +5,11 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { - internal static class LabelFilters - { - public static readonly string Null = "\0"; - - public static readonly string Any = "*"; - } - internal static class StringExtensions { public static string NormalizeNull(this string s) { - return s == LabelFilters.Null ? null : s; + return s == LabelFilter.Null ? null : s; } public static string ToBase64String(this string s) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 1e8beae69..263907624 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -102,7 +102,8 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = FeatureFlagSelectors.AppendUnique(new KeyValueSelector { KeyFilter = featureFlagPrefix, - LabelFilter = labelFilter + LabelFilter = labelFilter, + IsFeatureFlagSelector = true }); return this; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/GetKeyValueChangeCollectionOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/GetKeyValueChangeCollectionOptions.cs deleted file mode 100644 index 5cb9b83d7..000000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/GetKeyValueChangeCollectionOptions.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration -{ - internal class GetKeyValueChangeCollectionOptions - { - public string KeyFilter { get; set; } - public string Label { get; set; } - public bool RequestTracingEnabled { get; set; } - public RequestTracingOptions RequestTracingOptions { get; set; } - } -} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs new file mode 100644 index 000000000..08c95751e --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs @@ -0,0 +1,13 @@ +using Azure.Data.AppConfiguration; +using Azure; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal interface IConfigurationSettingPageIterator + { + IAsyncEnumerable> IteratePages(AsyncPageable pageable); + + IAsyncEnumerable> IteratePages(AsyncPageable pageable, IEnumerable matchConditions); + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs index 11442dcb4..4f9994062 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs @@ -25,14 +25,19 @@ public static string BuildFeatureFlagsUnchangedMessage(string endpoint) return $"{LoggingConstants.RefreshFeatureFlagsUnchanged} Endpoint:'{endpoint?.TrimEnd('/')}'"; } - public static string BuildFeatureFlagReadMessage(string key, string label, string endpoint) - { - return $"{LoggingConstants.RefreshFeatureFlagRead} Key:'{key}' Label:'{label}' Endpoint:'{endpoint?.TrimEnd('/')}'"; + public static string BuildFeatureFlagsUpdatedMessage() + { + return LoggingConstants.RefreshFeatureFlagsUpdated; + } + + public static string BuildSelectedKeyValueCollectionsUnchangedMessage(string endpoint) + { + return $"{LoggingConstants.RefreshSelectedKeyValueCollectionsUnchanged} Endpoint:'{endpoint?.TrimEnd('/')}'"; } - public static string BuildFeatureFlagUpdatedMessage(string key) + public static string BuildSelectedKeyValuesAndFeatureFlagsUpdatedMessage() { - return $"{LoggingConstants.RefreshFeatureFlagUpdated} Key:'{key}'"; + return LoggingConstants.RefreshSelectedKeyValuesAndFeatureFlagsUpdated; } public static string BuildKeyVaultSecretReadMessage(string key, string label) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 5491d04de..54bda1a49 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -24,6 +24,11 @@ public class KeyValueSelector /// public string SnapshotName { get; set; } + /// + /// A boolean that signifies whether this selector is intended to select feature flags. + /// + public bool IsFeatureFlagSelector { get; set; } + /// /// Determines whether the specified object is equal to the current object. /// diff --git a/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs b/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs index 886d0a77b..aaee0b9c9 100644 --- a/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs +++ b/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs @@ -17,6 +17,8 @@ public MockResponse(int status, string reasonPhrase = null) { Status = status; ReasonPhrase = reasonPhrase; + + AddHeader(new HttpHeader(HttpHeader.Names.ETag, "\"" + Guid.NewGuid().ToString() + "\"")); } public override int Status { get; } diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 7e49e8abd..f7ebf127e 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -768,19 +768,24 @@ public void UsesFeatureFlags() [Fact] public async Task WatchesFeatureFlags() { + var mockResponse = new MockResponse(200); + var featureFlags = new List { _kv }; - var mockResponse = new Mock(); + var mockAsyncPageable = new MockAsyncPageable(featureFlags); + var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(featureFlags)); + .Callback(() => mockAsyncPageable.UpdateCollection(featureFlags)) + .Returns(mockAsyncPageable); IConfigurationRefresher refresher = null; var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval)); refresher = options.GetRefresher(); @@ -820,7 +825,7 @@ public async Task WatchesFeatureFlags() ", label: default, contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", - eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")); featureFlags.Add(_kv2); @@ -839,11 +844,12 @@ public async Task WatchesFeatureFlagsUsingCacheExpirationInterval() { var featureFlags = new List { _kv }; - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + var mockAsyncPageable = new MockAsyncPageable(featureFlags); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(featureFlags)); + .Callback(() => mockAsyncPageable.UpdateCollection(featureFlags)) + .Returns(mockAsyncPageable); var cacheExpirationInterval = TimeSpan.FromSeconds(1); @@ -852,6 +858,7 @@ public async Task WatchesFeatureFlagsUsingCacheExpirationInterval() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); options.UseFeatureFlags(o => o.CacheExpirationInterval = cacheExpirationInterval); refresher = options.GetRefresher(); @@ -910,17 +917,20 @@ public async Task SkipRefreshIfRefreshIntervalHasNotElapsed() { var featureFlags = new List { _kv }; - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + var mockAsyncPageable = new MockAsyncPageable(featureFlags); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(featureFlags)); + .Callback(() => mockAsyncPageable.UpdateCollection(featureFlags)) + .Returns(mockAsyncPageable); IConfigurationRefresher refresher = null; var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); options.UseFeatureFlags(o => o.SetRefreshInterval(TimeSpan.FromSeconds(10))); refresher = options.GetRefresher(); @@ -977,17 +987,20 @@ public async Task SkipRefreshIfCacheNotExpired() { var featureFlags = new List { _kv }; - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + var mockAsyncPageable = new MockAsyncPageable(featureFlags); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(featureFlags)); + .Callback(() => mockAsyncPageable.UpdateCollection(featureFlags)) + .Returns(mockAsyncPageable); IConfigurationRefresher refresher = null; var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); options.UseFeatureFlags(o => o.CacheExpirationInterval = TimeSpan.FromSeconds(10)); refresher = options.GetRefresher(); @@ -1096,17 +1109,22 @@ public void QueriesFeatureFlags() } [Fact] - public async Task UsesEtagForFeatureFlagRefresh() + public async Task DoesNotUseEtagForFeatureFlagRefresh() { + var mockAsyncPageable = new MockAsyncPageable(new List { _kv }); + var mockClient = new Mock(MockBehavior.Strict); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(new List { _kv })); + .Callback(() => mockAsyncPageable.UpdateCollection(new List { _kv })) + .Returns(mockAsyncPageable); IConfigurationRefresher refresher = null; var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval)); refresher = options.GetRefresher(); @@ -1137,6 +1155,7 @@ public void SelectFeatureFlags() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); options.UseFeatureFlags(ff => { ff.SetRefreshInterval(RefreshInterval); @@ -1486,18 +1505,19 @@ public async Task DifferentCacheExpirationsForMultipleFeatureFlagRegistrations() IConfigurationRefresher refresher = null; var featureFlagCollection = new List(_featureFlagCollection); + var mockAsyncPageable = new MockAsyncPageable(featureFlagCollection); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(() => - { - return new MockAsyncPageable(featureFlagCollection.Where(s => + .Callback(() => mockAsyncPageable.UpdateCollection(featureFlagCollection.Where(s => (s.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker + prefix1) && s.Label == label1) || - (s.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker + prefix2) && s.Label == label2 && s.Key != FeatureManagementConstants.FeatureFlagMarker + "App2_Feature3")).ToList()); - }); + (s.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker + prefix2) && s.Label == label2 && s.Key != FeatureManagementConstants.FeatureFlagMarker + "App2_Feature3")).ToList())) + .Returns(mockAsyncPageable); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); options.UseFeatureFlags(ff => { ff.SetRefreshInterval(refreshInterval1); @@ -1656,18 +1676,18 @@ public async Task SelectAndRefreshSingleFeatureFlag() var label1 = "App1_Label"; IConfigurationRefresher refresher = null; var featureFlagCollection = new List(_featureFlagCollection); + var mockAsyncPageable = new MockAsyncPageable(featureFlagCollection); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(() => - { - return new MockAsyncPageable(featureFlagCollection.Where(s => - s.Key.Equals(FeatureManagementConstants.FeatureFlagMarker + prefix1) && s.Label == label1).ToList()); - }); + .Callback(() => mockAsyncPageable.UpdateCollection(featureFlagCollection.Where(s => + s.Key.Equals(FeatureManagementConstants.FeatureFlagMarker + prefix1) && s.Label == label1).ToList())) + .Returns(mockAsyncPageable); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); options.UseFeatureFlags(ff => { ff.SetRefreshInterval(RefreshInterval); @@ -1720,8 +1740,17 @@ public async Task ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefre var mockClient = new Mock(MockBehavior.Strict); + var mockAsyncPageable = new MockAsyncPageable(featureFlags); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(featureFlags)); + .Callback(() => mockAsyncPageable.UpdateCollection(featureFlags)) + .Returns(mockAsyncPageable); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetIfChanged); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetTestKey); string informationalInvocation = ""; string verboseInvocation = ""; @@ -1745,6 +1774,7 @@ public async Task ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefre .AddAzureAppConfiguration(options => { options.ClientManager = mockClientManager; + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval)); refresher = options.GetRefresher(); }) @@ -1753,10 +1783,10 @@ public async Task ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefre Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]); featureFlags[0] = ConfigurationModelFactory.ConfigurationSetting( - key: FeatureManagementConstants.FeatureFlagMarker + "myFeature1", + key: FeatureManagementConstants.FeatureFlagMarker + "myFeature2", value: @" { - ""id"": ""MyFeature"", + ""id"": ""MyFeature2"", ""description"": ""The new beta version of our web site."", ""display_name"": ""Beta Feature"", ""enabled"": true, @@ -1771,21 +1801,19 @@ public async Task ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefre ", label: default, contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", - eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")); Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); - Assert.Equal("AllUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); - Assert.Contains(LogHelper.BuildFeatureFlagReadMessage("myFeature1", null, TestHelpers.PrimaryConfigStoreEndpoint.ToString().TrimEnd('/')), verboseInvocation); - Assert.Contains(LogHelper.BuildFeatureFlagUpdatedMessage("myFeature1"), informationalInvocation); + Assert.Equal("AllUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]); + Assert.Contains(LogHelper.BuildFeatureFlagsUpdatedMessage(), informationalInvocation); featureFlags.RemoveAt(0); Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Null(config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); - Assert.Contains(LogHelper.BuildFeatureFlagReadMessage("myFeature1", null, TestHelpers.PrimaryConfigStoreEndpoint.ToString().TrimEnd('/')), verboseInvocation); - Assert.Contains(LogHelper.BuildFeatureFlagUpdatedMessage("myFeature1"), informationalInvocation); + Assert.Contains(LogHelper.BuildFeatureFlagsUpdatedMessage(), informationalInvocation); } [Fact] @@ -1796,8 +1824,11 @@ public async Task ValidateFeatureFlagsUnchangedLogged() var mockClient = new Mock(MockBehavior.Strict); + var mockAsyncPageable = new MockAsyncPageable(featureFlags); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(featureFlags)); + .Callback(() => mockAsyncPageable.UpdateCollection(featureFlags)) + .Returns(mockAsyncPageable); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Func>)GetIfChanged); @@ -1821,6 +1852,7 @@ public async Task ValidateFeatureFlagsUnchangedLogged() .AddAzureAppConfiguration(options => { options.ClientManager = mockClientManager; + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval)); options.ConfigureRefresh(refreshOptions => { @@ -1870,9 +1902,11 @@ public async Task MapTransformFeatureFlagWithRefresh() IConfigurationRefresher refresher = null; var featureFlags = new List { _kv }; var mockClient = new Mock(MockBehavior.Strict); + var mockAsyncPageable = new MockAsyncPageable(featureFlags); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(featureFlags)); + .Callback(() => mockAsyncPageable.UpdateCollection(featureFlags)) + .Returns(mockAsyncPageable); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Func>)GetIfChanged); @@ -1945,7 +1979,7 @@ public async Task MapTransformFeatureFlagWithRefresh() ", label: default, contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", - eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")); Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs index 06c88040b..25edb546f 100644 --- a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs @@ -505,9 +505,9 @@ public void DoesNotThrowKeyVaultExceptionWhenProviderIsOptional() .Returns(new MockAsyncPageable(new List { _kv })); var mockKeyValueAdapter = new Mock(MockBehavior.Strict); - mockKeyValueAdapter.Setup(adapter => adapter.CanProcess(_kv)) + mockKeyValueAdapter.Setup(adapter => adapter.CanProcess(It.IsAny())) .Returns(true); - mockKeyValueAdapter.Setup(adapter => adapter.ProcessKeyValue(_kv, It.IsAny(), It.IsAny(), It.IsAny())) + mockKeyValueAdapter.Setup(adapter => adapter.ProcessKeyValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Throws(new KeyVaultReferenceException("Key vault error", null)); mockKeyValueAdapter.Setup(adapter => adapter.OnChangeDetected(null)); mockKeyValueAdapter.Setup(adapter => adapter.OnConfigUpdated()); diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index 6edc1a9ae..a7fdbd183 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -7,6 +7,7 @@ using Azure.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; using Microsoft.Extensions.Logging.Abstractions; using Moq; using System; @@ -1056,6 +1057,170 @@ public void RefreshTests_RefreshIsCancelled() Assert.Equal("TestValue1", config["TestKey1"]); } + [Fact] + public async Task RefreshTests_SelectedKeysRefreshWithRegisterAll() + { + IConfigurationRefresher refresher = null; + var mockClient = GetMockConfigurationClient(); + + var mockAsyncPageable = new MockAsyncPageable(_kvCollection); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Callback(() => mockAsyncPageable.UpdateCollection(_kvCollection)) + .Returns(mockAsyncPageable); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select("TestKey*"); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Equal("TestValue3", config["TestKey3"]); + FirstKeyValue.Value = "newValue1"; + _kvCollection[2].Value = "newValue3"; + + // Wait for the cache to expire + Thread.Sleep(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("newValue1", config["TestKey1"]); + Assert.Equal("newValue3", config["TestKey3"]); + + _kvCollection.RemoveAt(2); + + // Wait for the cache to expire + Thread.Sleep(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("newValue1", config["TestKey1"]); + Assert.Null(config["TestKey3"]); + } + + [Fact] + public async Task RefreshTests_RegisterAllRefreshesFeatureFlags() + { + IConfigurationRefresher refresher = null; + var mockClient = GetMockConfigurationClient(); + + var featureFlags = new List { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "myFeature", + value: @" + { + ""id"": ""MyFeature"", + ""description"": ""The new beta version of our web site."", + ""display_name"": ""Beta Feature"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""SuperUsers"" + } + ] + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) + }; + + var mockAsyncPageableKv = new MockAsyncPageable(_kvCollection); + + var mockAsyncPageableFf = new MockAsyncPageable(featureFlags); + + MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) + { + if (selector.KeyFilter.StartsWith(FeatureManagementConstants.FeatureFlagMarker)) + { + mockAsyncPageableFf.UpdateCollection(featureFlags); + + return mockAsyncPageableFf; + } + + mockAsyncPageableKv.UpdateCollection(_kvCollection); + + return mockAsyncPageableKv; + } + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns((Func)GetTestKeys); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select("TestKey*"); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + options.UseFeatureFlags(); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); + + FirstKeyValue.Value = "newValue1"; + featureFlags[0] = ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "myFeature", + value: @" + { + ""id"": ""MyFeature"", + ""description"": ""The new beta version of our web site."", + ""display_name"": ""Beta Feature"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""AllUsers"" + } + ] + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")); + + // Wait for the cache to expire + Thread.Sleep(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("newValue1", config["TestKey1"]); + Assert.Equal("AllUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); + + FirstKeyValue.Value = "newerValue1"; + featureFlags.RemoveAt(0); + + // Wait for the cache to expire + Thread.Sleep(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("newerValue1", config["TestKey1"]); + Assert.Null(config["FeatureManagement:MyFeature"]); + } + #if NET8_0 [Fact] public void RefreshTests_ChainedConfigurationProviderUsedAsRootForRefresherProvider() diff --git a/tests/Tests.AzureAppConfiguration/TestHelper.cs b/tests/Tests.AzureAppConfiguration/TestHelper.cs index bc7989b25..e3bf55006 100644 --- a/tests/Tests.AzureAppConfiguration/TestHelper.cs +++ b/tests/Tests.AzureAppConfiguration/TestHelper.cs @@ -3,6 +3,7 @@ // using Azure; using Azure.Core; +using Azure.Core.Testing; using Azure.Data.AppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.Logging; @@ -10,6 +11,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Text.Json; using System.Threading; @@ -155,19 +157,69 @@ public static bool ValidateLog(Mock logger, string expectedMessage, Log class MockAsyncPageable : AsyncPageable { - private readonly List _collection; + private readonly List _collection = new List(); + private int _status; public MockAsyncPageable(List collection) { - _collection = collection; + foreach (ConfigurationSetting setting in collection) + { + var newSetting = new ConfigurationSetting(setting.Key, setting.Value, setting.Label, setting.ETag); + + newSetting.ContentType = setting.ContentType; + + _collection.Add(newSetting); + } + + _status = 200; + } + + public void UpdateCollection(List newCollection) + { + if (_collection.Count() == newCollection.Count() && + _collection.All(setting => newCollection.Any(newSetting => + setting.Key == newSetting.Key && + setting.Value == newSetting.Value && + setting.Label == newSetting.Label && + setting.ETag == newSetting.ETag))) + { + _status = 304; + } + else + { + _status = 200; + + _collection.Clear(); + + foreach (ConfigurationSetting setting in newCollection) + { + var newSetting = new ConfigurationSetting(setting.Key, setting.Value, setting.Label, setting.ETag); + + newSetting.ContentType = setting.ContentType; + + _collection.Add(newSetting); + } + } } #pragma warning disable 1998 public async override IAsyncEnumerable> AsPages(string continuationToken = null, int? pageSizeHint = null) #pragma warning restore 1998 { - yield return Page.FromValues(_collection, null, new Mock().Object); + yield return Page.FromValues(_collection, null, new MockResponse(_status)); + } + } + + internal class MockConfigurationSettingPageIterator : IConfigurationSettingPageIterator + { + public IAsyncEnumerable> IteratePages(AsyncPageable pageable, IEnumerable matchConditions) + { + return pageable.AsPages(); + } + public IAsyncEnumerable> IteratePages(AsyncPageable pageable) + { + return pageable.AsPages(); } } @@ -182,7 +234,7 @@ public MockPageable(List collection) public override IEnumerable> AsPages(string continuationToken = null, int? pageSizeHint = null) { - yield return Page.FromValues(_collection, null, new Mock().Object); + yield return Page.FromValues(_collection, null, new MockResponse(200)); } } } From 6dc9ae2a42926952eb21339526264407bba5d5ee Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:15:26 -0800 Subject: [PATCH 10/48] Give the users the ability to have control over ConfigurationClient instance(s) used by the provider (#598) (#617) * Introduced a new `AzureAppConfigurationClientFactory` class to handle the creation of `ConfigurationClient` instances * remove clients dictionary since we will not have hits and clients are already stored in ConfigurationClientManager * revert * add license + remove unused usings * ran dotnet format * add capability of fallback to different stores * add explicit type * address comments * remove scheme validation --------- Co-authored-by: Sami Sadfa <71456174+samsadsam@users.noreply.github.com> Co-authored-by: Sami Sadfa --- .../AzureAppConfigurationClientFactory.cs | 74 +++++++++++++++++++ .../AzureAppConfigurationOptions.cs | 17 +++++ .../AzureAppConfigurationSource.cs | 32 ++++---- .../ConfigurationClientManager.cs | 64 +++------------- ...Configuration.AzureAppConfiguration.csproj | 1 + .../FailoverTests.cs | 19 +++-- 6 files changed, 128 insertions(+), 79 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs new file mode 100644 index 000000000..6127822d8 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Core; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Azure; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class AzureAppConfigurationClientFactory : IAzureClientFactory + { + private readonly ConfigurationClientOptions _clientOptions; + + private readonly TokenCredential _credential; + private readonly IEnumerable _connectionStrings; + + public AzureAppConfigurationClientFactory( + IEnumerable connectionStrings, + ConfigurationClientOptions clientOptions) + { + if (connectionStrings == null || !connectionStrings.Any()) + { + throw new ArgumentNullException(nameof(connectionStrings)); + } + + _connectionStrings = connectionStrings; + + _clientOptions = clientOptions ?? throw new ArgumentNullException(nameof(clientOptions)); + } + + public AzureAppConfigurationClientFactory( + TokenCredential credential, + ConfigurationClientOptions clientOptions) + { + _credential = credential ?? throw new ArgumentNullException(nameof(credential)); + _clientOptions = clientOptions ?? throw new ArgumentNullException(nameof(clientOptions)); + } + + public ConfigurationClient CreateClient(string endpoint) + { + if (string.IsNullOrEmpty(endpoint)) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri uriResult)) + { + throw new ArgumentException("Invalid host URI."); + } + + if (_credential != null) + { + return new ConfigurationClient(uriResult, _credential, _clientOptions); + } + + string connectionString = _connectionStrings.FirstOrDefault(cs => ConnectionStringUtils.Parse(cs, ConnectionStringUtils.EndpointSection) == endpoint); + + // + // falback to the first connection string + if (connectionString == null) + { + string id = ConnectionStringUtils.Parse(_connectionStrings.First(), ConnectionStringUtils.IdSection); + string secret = ConnectionStringUtils.Parse(_connectionStrings.First(), ConnectionStringUtils.SecretSection); + + connectionString = ConnectionStringUtils.Build(uriResult, id, secret); + } + + return new ConfigurationClient(connectionString, _clientOptions); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 9391f21b1..6e7f595a4 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -3,6 +3,7 @@ // using Azure.Core; using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; @@ -146,6 +147,11 @@ internal IEnumerable Adapters /// internal StartupOptions Startup { get; set; } = new StartupOptions(); + /// + /// Client factory that is responsible for creating instances of ConfigurationClient. + /// + internal IAzureClientFactory ClientFactory { get; private set; } + /// /// Initializes a new instance of the class. /// @@ -162,6 +168,17 @@ public AzureAppConfigurationOptions() _selectors = new List { new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null } }; } + /// + /// Sets the client factory used to create ConfigurationClient instances. + /// + /// The client factory. + /// The current instance. + public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory factory) + { + ClientFactory = factory ?? throw new ArgumentNullException(nameof(factory)); + return this; + } + /// /// Specify what key-values to include in the configuration provider. /// can be called multiple times to include multiple sets of key-values. diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs index dee620060..83d20e2fb 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -1,7 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Azure; using System; +using System.Collections.Generic; +using System.Linq; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { @@ -29,35 +33,33 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) try { AzureAppConfigurationOptions options = _optionsProvider(); - IConfigurationClientManager clientManager; if (options.ClientManager != null) { - clientManager = options.ClientManager; + return new AzureAppConfigurationProvider(options.ClientManager, options, _optional); } - else if (options.ConnectionStrings != null) + + IEnumerable endpoints; + IAzureClientFactory clientFactory = options.ClientFactory; + + if (options.ConnectionStrings != null) { - clientManager = new ConfigurationClientManager( - options.ConnectionStrings, - options.ClientOptions, - options.ReplicaDiscoveryEnabled, - options.LoadBalancingEnabled); + endpoints = options.ConnectionStrings.Select(cs => new Uri(ConnectionStringUtils.Parse(cs, ConnectionStringUtils.EndpointSection))); + + clientFactory ??= new AzureAppConfigurationClientFactory(options.ConnectionStrings, options.ClientOptions); } else if (options.Endpoints != null && options.Credential != null) { - clientManager = new ConfigurationClientManager( - options.Endpoints, - options.Credential, - options.ClientOptions, - options.ReplicaDiscoveryEnabled, - options.LoadBalancingEnabled); + endpoints = options.Endpoints; + + clientFactory ??= new AzureAppConfigurationClientFactory(options.Credential, options.ClientOptions); } else { throw new ArgumentException($"Please call {nameof(AzureAppConfigurationOptions)}.{nameof(AzureAppConfigurationOptions.Connect)} to specify how to connect to Azure App Configuration."); } - provider = new AzureAppConfigurationProvider(clientManager, options, _optional); + provider = new AzureAppConfigurationProvider(new ConfigurationClientManager(clientFactory, endpoints, options.ReplicaDiscoveryEnabled, options.LoadBalancingEnabled), options, _optional); } catch (InvalidOperationException ex) // InvalidOperationException is thrown when any problems are found while configuring AzureAppConfigurationOptions or when SDK fails to create a configurationClient. { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs index a0215ca37..61840d036 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs @@ -2,10 +2,10 @@ // Licensed under the MIT license. // -using Azure.Core; using Azure.Data.AppConfiguration; using DnsClient; using DnsClient.Protocol; +using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using System; using System.Collections.Generic; @@ -26,12 +26,11 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration /// internal class ConfigurationClientManager : IConfigurationClientManager, IDisposable { + private readonly IAzureClientFactory _clientFactory; private readonly IList _clients; + private readonly Uri _endpoint; - private readonly string _secret; - private readonly string _id; - private readonly TokenCredential _credential; - private readonly ConfigurationClientOptions _clientOptions; + private readonly bool _replicaDiscoveryEnabled; private readonly SrvLookupClient _srvLookupClient; private readonly string _validDomain; @@ -52,61 +51,20 @@ internal class ConfigurationClientManager : IConfigurationClientManager, IDispos internal int RefreshClientsCalled { get; set; } = 0; public ConfigurationClientManager( - IEnumerable connectionStrings, - ConfigurationClientOptions clientOptions, - bool replicaDiscoveryEnabled, - bool loadBalancingEnabled) - { - if (connectionStrings == null || !connectionStrings.Any()) - { - throw new ArgumentNullException(nameof(connectionStrings)); - } - - string connectionString = connectionStrings.First(); - _endpoint = new Uri(ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.EndpointSection)); - _secret = ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.SecretSection); - _id = ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.IdSection); - _clientOptions = clientOptions; - _replicaDiscoveryEnabled = replicaDiscoveryEnabled; - - // If load balancing is enabled, shuffle the passed in connection strings to randomize the endpoint used on startup - if (loadBalancingEnabled) - { - connectionStrings = connectionStrings.ToList().Shuffle(); - } - - _validDomain = GetValidDomain(_endpoint); - _srvLookupClient = new SrvLookupClient(); - - _clients = connectionStrings - .Select(cs => - { - var endpoint = new Uri(ConnectionStringUtils.Parse(cs, ConnectionStringUtils.EndpointSection)); - return new ConfigurationClientWrapper(endpoint, new ConfigurationClient(cs, _clientOptions)); - }) - .ToList(); - } - - public ConfigurationClientManager( + IAzureClientFactory clientFactory, IEnumerable endpoints, - TokenCredential credential, - ConfigurationClientOptions clientOptions, bool replicaDiscoveryEnabled, bool loadBalancingEnabled) { + _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + if (endpoints == null || !endpoints.Any()) { throw new ArgumentNullException(nameof(endpoints)); } - if (credential == null) - { - throw new ArgumentNullException(nameof(credential)); - } - _endpoint = endpoints.First(); - _credential = credential; - _clientOptions = clientOptions; + _replicaDiscoveryEnabled = replicaDiscoveryEnabled; // If load balancing is enabled, shuffle the passed in endpoints to randomize the endpoint used on startup @@ -119,7 +77,7 @@ public ConfigurationClientManager( _srvLookupClient = new SrvLookupClient(); _clients = endpoints - .Select(endpoint => new ConfigurationClientWrapper(endpoint, new ConfigurationClient(endpoint, _credential, _clientOptions))) + .Select(endpoint => new ConfigurationClientWrapper(endpoint, clientFactory.CreateClient(endpoint.AbsoluteUri))) .ToList(); } @@ -289,9 +247,7 @@ private async Task RefreshFallbackClients(CancellationToken cancellationToken) { var targetEndpoint = new Uri($"https://{host}"); - var configClient = _credential == null - ? new ConfigurationClient(ConnectionStringUtils.Build(targetEndpoint, _id, _secret), _clientOptions) - : new ConfigurationClient(targetEndpoint, _credential, _clientOptions); + ConfigurationClient configClient = _clientFactory.CreateClient(targetEndpoint.AbsoluteUri); newDynamicClients.Add(new ConfigurationClientWrapper(targetEndpoint, configClient)); } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index aeedd6e67..369f5dbdf 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -19,6 +19,7 @@ + diff --git a/tests/Tests.AzureAppConfiguration/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/FailoverTests.cs index 86ea96b97..929f9befc 100644 --- a/tests/Tests.AzureAppConfiguration/FailoverTests.cs +++ b/tests/Tests.AzureAppConfiguration/FailoverTests.cs @@ -268,10 +268,11 @@ public void FailOverTests_AutoFailover() [Fact] public void FailOverTests_ValidateEndpoints() { + var clientFactory = new AzureAppConfigurationClientFactory(new DefaultAzureCredential(), new ConfigurationClientOptions()); + var configClientManager = new ConfigurationClientManager( + clientFactory, new[] { new Uri("https://foobar.azconfig.io") }, - new DefaultAzureCredential(), - new ConfigurationClientOptions(), true, false); @@ -285,9 +286,8 @@ public void FailOverTests_ValidateEndpoints() Assert.False(configClientManager.IsValidEndpoint("azure.azconfig.bad.io")); var configClientManager2 = new ConfigurationClientManager( + clientFactory, new[] { new Uri("https://foobar.appconfig.azure.com") }, - new DefaultAzureCredential(), - new ConfigurationClientOptions(), true, false); @@ -301,9 +301,8 @@ public void FailOverTests_ValidateEndpoints() Assert.False(configClientManager2.IsValidEndpoint("azure.appconfigbad.azure.com")); var configClientManager3 = new ConfigurationClientManager( + clientFactory, new[] { new Uri("https://foobar.azconfig-test.io") }, - new DefaultAzureCredential(), - new ConfigurationClientOptions(), true, false); @@ -311,9 +310,8 @@ public void FailOverTests_ValidateEndpoints() Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig.io")); var configClientManager4 = new ConfigurationClientManager( + clientFactory, new[] { new Uri("https://foobar.z1.appconfig-test.azure.com") }, - new DefaultAzureCredential(), - new ConfigurationClientOptions(), true, false); @@ -325,10 +323,11 @@ public void FailOverTests_ValidateEndpoints() [Fact] public void FailOverTests_GetNoDynamicClient() { + var clientFactory = new AzureAppConfigurationClientFactory(new DefaultAzureCredential(), new ConfigurationClientOptions()); + var configClientManager = new ConfigurationClientManager( + clientFactory, new[] { new Uri("https://azure.azconfig.io") }, - new DefaultAzureCredential(), - new ConfigurationClientOptions(), true, false); From 68f4b8f93bf56b0b2ad5b199bcd26a611e49d3be Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Thu, 27 Feb 2025 16:47:51 -0800 Subject: [PATCH 11/48] first draft tag filtering support --- .../AzureAppConfigurationOptions.cs | 15 +++++++++++++-- .../AzureAppConfigurationProvider.cs | 5 +++++ .../FeatureManagement/FeatureFlagOptions.cs | 13 ++++++++++++- ...ons.Configuration.AzureAppConfiguration.csproj | 2 +- .../Models/KeyValueSelector.cs | 7 +++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 6e7f595a4..1dbdc8ccc 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -198,7 +198,10 @@ public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). /// - public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null) + /// + /// TODO + /// + public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IDictionary tagsFilter = null) { if (string.IsNullOrEmpty(keyFilter)) { @@ -223,10 +226,18 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter _selectCalled = true; } + IList tagsFilterList = new List(); + + if (tagsFilter != null) + { + tagsFilterList = tagsFilter.Select(kvp => $"{kvp.Key}={kvp.Value}").ToList(); + } + _selectors.AppendUnique(new KeyValueSelector { KeyFilter = keyFilter, - LabelFilter = labelFilter + LabelFilter = labelFilter, + TagsFilter = tagsFilterList }); return this; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index ebe8992c1..99545deb4 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -792,6 +792,11 @@ private async Task> LoadSelected( LabelFilter = loadOption.LabelFilter }; + foreach (string tagFilter in loadOption.TagsFilter) + { + selector.TagsFilter.Add(tagFilter); + } + var matchConditions = new List(); await CallWithRequestTracing(async () => diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 263907624..cbe9a9b72 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -74,7 +74,10 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) /// The label filter to apply when querying Azure App Configuration for feature flags. By default the null label will be used. Built-in label filter options: /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). /// - public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null) + /// + /// TODO + /// + public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IDictionary tagsFilter = null) { if (string.IsNullOrEmpty(featureFlagFilter)) { @@ -99,10 +102,18 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = string featureFlagPrefix = FeatureManagementConstants.FeatureFlagMarker + featureFlagFilter; + IList tagsFilterList = new List(); + + if (tagsFilter != null) + { + tagsFilterList = tagsFilter.Select(kvp => $"{kvp.Key}={kvp.Value}").ToList(); + } + FeatureFlagSelectors.AppendUnique(new KeyValueSelector { KeyFilter = featureFlagPrefix, LabelFilter = labelFilter, + TagsFilter = tagsFilterList, IsFeatureFlagSelector = true }); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index 369f5dbdf..45bb35f30 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 54bda1a49..95ae14e0b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System.Collections.Generic; + namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Models { /// @@ -19,6 +21,11 @@ public class KeyValueSelector /// public string LabelFilter { get; set; } + /// + /// A filter that determines what tags to use when selecting key-values for the the configuration provider. + /// + public IList TagsFilter { get; set; } + /// /// The name of the Azure App Configuration snapshot to use when selecting key-values for the configuration provider. /// From 3b12ee4f2ef6c8a2477b1814a345dc34676ce2d7 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Fri, 28 Feb 2025 09:39:37 -0800 Subject: [PATCH 12/48] add alternate APIs --- .../AzureAppConfigurationOptions.cs | 23 +++++++++++++++++++ .../FeatureManagement/FeatureFlagOptions.cs | 18 +++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 1dbdc8ccc..180cbec41 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -179,6 +179,29 @@ public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory + /// Specify what key-values to include in the configuration provider. + /// can be called multiple times to include multiple sets of key-values. + /// + /// + /// The key filter to apply when querying Azure App Configuration for key-values. + /// An asterisk (*) can be added to the end to return all key-values whose key begins with the key filter. + /// e.g. key filter `abc*` returns all key-values whose key starts with `abc`. + /// A comma (,) can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. + /// Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. + /// E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. + /// For all other cases the characters: asterisk (*), comma (,), and backslash (\) are reserved. Reserved characters must be escaped using a backslash (\). + /// e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. + /// Built-in key filter options: . + /// + /// + /// TODO + /// + public AzureAppConfigurationOptions Select(string keyFilter, IDictionary tagsFilter) + { + return Select(keyFilter, LabelFilter.Null, tagsFilter); + } + /// /// Specify what key-values to include in the configuration provider. /// can be called multiple times to include multiple sets of key-values. diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index cbe9a9b72..fd7b1c2c9 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -60,6 +60,24 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) return this; } + /// + /// Specify what feature flags to include in the configuration provider. + /// can be called multiple times to include multiple sets of feature flags. + /// + /// + /// The filter to apply to feature flag names when querying Azure App Configuration for feature flags. + /// For example, you can select all feature flags that begin with "MyApp" by setting the featureflagFilter to "MyApp*". + /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). + /// Built-in feature flag filter options: . + /// + /// + /// TODO + /// + public FeatureFlagOptions Select(string featureFlagFilter, IDictionary tagsFilter) + { + return Select(featureFlagFilter, LabelFilter.Null, tagsFilter); + } + /// /// Specify what feature flags to include in the configuration provider. /// can be called multiple times to include multiple sets of feature flags. From 7bdb2d0b8341ee31be732b05ef652e8d84ad0c95 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Thu, 6 Mar 2025 14:41:41 -0800 Subject: [PATCH 13/48] change to use ienumerable --- .../AzureAppConfigurationOptions.cs | 34 ++----------------- .../Models/KeyValueSelector.cs | 2 +- 2 files changed, 3 insertions(+), 33 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 180cbec41..0c1b09f7b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -179,29 +179,6 @@ public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory - /// Specify what key-values to include in the configuration provider. - /// can be called multiple times to include multiple sets of key-values. - /// - /// - /// The key filter to apply when querying Azure App Configuration for key-values. - /// An asterisk (*) can be added to the end to return all key-values whose key begins with the key filter. - /// e.g. key filter `abc*` returns all key-values whose key starts with `abc`. - /// A comma (,) can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. - /// Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. - /// E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. - /// For all other cases the characters: asterisk (*), comma (,), and backslash (\) are reserved. Reserved characters must be escaped using a backslash (\). - /// e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. - /// Built-in key filter options: . - /// - /// - /// TODO - /// - public AzureAppConfigurationOptions Select(string keyFilter, IDictionary tagsFilter) - { - return Select(keyFilter, LabelFilter.Null, tagsFilter); - } - /// /// Specify what key-values to include in the configuration provider. /// can be called multiple times to include multiple sets of key-values. @@ -224,7 +201,7 @@ public AzureAppConfigurationOptions Select(string keyFilter, IDictionary /// TODO /// - public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IDictionary tagsFilter = null) + public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilter = null) { if (string.IsNullOrEmpty(keyFilter)) { @@ -249,18 +226,11 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter _selectCalled = true; } - IList tagsFilterList = new List(); - - if (tagsFilter != null) - { - tagsFilterList = tagsFilter.Select(kvp => $"{kvp.Key}={kvp.Value}").ToList(); - } - _selectors.AppendUnique(new KeyValueSelector { KeyFilter = keyFilter, LabelFilter = labelFilter, - TagsFilter = tagsFilterList + TagsFilter = tagsFilter }); return this; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 95ae14e0b..29f5fdf84 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -24,7 +24,7 @@ public class KeyValueSelector /// /// A filter that determines what tags to use when selecting key-values for the the configuration provider. /// - public IList TagsFilter { get; set; } + public IEnumerable TagsFilter { get; set; } /// /// The name of the Azure App Configuration snapshot to use when selecting key-values for the configuration provider. From 0cfdec658a841c515662e75f35ddd8e3c527628d Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Thu, 6 Mar 2025 14:44:44 -0800 Subject: [PATCH 14/48] update featureflagoptions to match main options --- .../FeatureManagement/FeatureFlagOptions.cs | 29 ++----------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index fd7b1c2c9..93967c02c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -60,24 +60,6 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) return this; } - /// - /// Specify what feature flags to include in the configuration provider. - /// can be called multiple times to include multiple sets of feature flags. - /// - /// - /// The filter to apply to feature flag names when querying Azure App Configuration for feature flags. - /// For example, you can select all feature flags that begin with "MyApp" by setting the featureflagFilter to "MyApp*". - /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). - /// Built-in feature flag filter options: . - /// - /// - /// TODO - /// - public FeatureFlagOptions Select(string featureFlagFilter, IDictionary tagsFilter) - { - return Select(featureFlagFilter, LabelFilter.Null, tagsFilter); - } - /// /// Specify what feature flags to include in the configuration provider. /// can be called multiple times to include multiple sets of feature flags. @@ -95,7 +77,7 @@ public FeatureFlagOptions Select(string featureFlagFilter, IDictionary /// TODO /// - public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IDictionary tagsFilter = null) + public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilter = null) { if (string.IsNullOrEmpty(featureFlagFilter)) { @@ -120,18 +102,11 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = string featureFlagPrefix = FeatureManagementConstants.FeatureFlagMarker + featureFlagFilter; - IList tagsFilterList = new List(); - - if (tagsFilter != null) - { - tagsFilterList = tagsFilter.Select(kvp => $"{kvp.Key}={kvp.Value}").ToList(); - } - FeatureFlagSelectors.AppendUnique(new KeyValueSelector { KeyFilter = featureFlagPrefix, LabelFilter = labelFilter, - TagsFilter = tagsFilterList, + TagsFilter = tagsFilter, IsFeatureFlagSelector = true }); From acc131fa2a9f4a129ce4c3339a2dca291159d59f Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Thu, 6 Mar 2025 15:03:45 -0800 Subject: [PATCH 15/48] update keyvalueselector equals and hashcode --- .../Models/KeyValueSelector.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 29f5fdf84..ef7b965d5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -22,7 +22,7 @@ public class KeyValueSelector public string LabelFilter { get; set; } /// - /// A filter that determines what tags to use when selecting key-values for the the configuration provider. + /// A filter that determines what tags to require when selecting key-values for the the configuration provider. /// public IEnumerable TagsFilter { get; set; } @@ -47,7 +47,8 @@ public override bool Equals(object obj) { return KeyFilter == selector.KeyFilter && LabelFilter == selector.LabelFilter - && SnapshotName == selector.SnapshotName; + && SnapshotName == selector.SnapshotName + && TagsFilter == selector.TagsFilter; } return false; @@ -61,7 +62,8 @@ public override int GetHashCode() { return (KeyFilter?.GetHashCode() ?? 0) ^ (LabelFilter?.GetHashCode() ?? 1) ^ - (SnapshotName?.GetHashCode() ?? 2); + (SnapshotName?.GetHashCode() ?? 2) ^ + (TagsFilter?.GetHashCode() ?? 3); } } } From 6d6f9d376cc89ef556d0c18aae733e0bce36c48d Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 11 Mar 2025 12:50:44 -0700 Subject: [PATCH 16/48] update param comments for selects --- .../AzureAppConfigurationOptions.cs | 4 +++- .../FeatureManagement/FeatureFlagOptions.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 0c1b09f7b..1b975be69 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -199,7 +199,9 @@ public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory /// - /// TODO + /// The tag filter to apply when querying Azure App Configuration for key-values. By default no tags will be used. + /// Each tag provided must follow the format "tag=value". A key-value will only be returned if its tags match all tags provided + /// in the filter, or if the filter is empty. /// public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilter = null) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 93967c02c..47bfce816 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -75,7 +75,9 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). /// /// - /// TODO + /// The tag filter to apply when querying Azure App Configuration for key-values. By default no tags will be used. + /// Each tag provided must follow the format "tag=value". A key-value will only be returned if its tags match all tags provided + /// in the filter, or if the filter is empty. /// public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilter = null) { From 4ab2dda65683af163f4309705e2309e092e22184 Mon Sep 17 00:00:00 2001 From: Sami Sadfa Date: Wed, 12 Mar 2025 13:21:07 -0700 Subject: [PATCH 17/48] fix merge conflict errors --- .../AzureAppConfigurationProvider.cs | 7 ------- .../Extensions/StringExtensions.cs | 7 +++++++ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index bed5c70a0..d7d629f89 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -576,13 +576,6 @@ private void SetDirty(TimeSpan? maxDelay) kvWatcher.NextRefreshTime = nextRefreshTime; } } - else - { - foreach (KeyValueWatcher kvWatcher in _options.IndividualKvWatchers) - { - kvWatcher.NextRefreshTime = nextRefreshTime; - } - } foreach (KeyValueWatcher featureFlagWatcher in _options.FeatureFlagWatchers) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs index a451ae880..615720305 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs @@ -11,5 +11,12 @@ public static string NormalizeNull(this string s) { return s == LabelFilter.Null ? null : s; } + + public static string ToBase64String(this string s) + { + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(s); + + return Convert.ToBase64String(bytes); + } } } From 8745c9d6341a8b6d09d1b7a4eca52daad02b8e6d Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Mon, 17 Mar 2025 12:06:55 -0700 Subject: [PATCH 18/48] add validation for tagsfilter param, add to comment --- .../AzureAppConfigurationOptions.cs | 12 ++++++++++++ .../AzureAppConfigurationProvider.cs | 7 +++++-- .../FeatureManagement/FeatureFlagOptions.cs | 12 ++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 1b975be69..cf7e747c5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -202,6 +202,7 @@ public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilter = null) { @@ -221,6 +222,17 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter labelFilter = LabelFilter.Null; } + if (tagsFilter != null) + { + foreach (var tag in tagsFilter) + { + if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) + { + throw new ArgumentException($"Tag '{tag}' does not follow the format \"tag=value\" or \"tag=\".", nameof(tagsFilter)); + } + } + } + if (!_selectCalled) { _selectors.Clear(); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 99545deb4..084a719a2 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -792,9 +792,12 @@ private async Task> LoadSelected( LabelFilter = loadOption.LabelFilter }; - foreach (string tagFilter in loadOption.TagsFilter) + if (loadOption.TagsFilter != null) { - selector.TagsFilter.Add(tagFilter); + foreach (string tagFilter in loadOption.TagsFilter) + { + selector.TagsFilter.Add(tagFilter); + } } var matchConditions = new List(); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 47bfce816..e79f9e447 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -78,6 +78,7 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) /// The tag filter to apply when querying Azure App Configuration for key-values. By default no tags will be used. /// Each tag provided must follow the format "tag=value". A key-value will only be returned if its tags match all tags provided /// in the filter, or if the filter is empty. + /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). /// public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilter = null) { @@ -102,6 +103,17 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); } + if (tagsFilter != null) + { + foreach (var tag in tagsFilter) + { + if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) + { + throw new ArgumentException($"Tag '{tag}' does not follow the format \"tag=value\" or \"tag=\".", nameof(tagsFilter)); + } + } + } + string featureFlagPrefix = FeatureManagementConstants.FeatureFlagMarker + featureFlagFilter; FeatureFlagSelectors.AppendUnique(new KeyValueSelector From f0724cdb3281844376f03c68f3dfc46f5938c984 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Mon, 17 Mar 2025 15:34:21 -0700 Subject: [PATCH 19/48] edit error message for format --- .../AzureAppConfigurationOptions.cs | 2 +- .../FeatureManagement/FeatureFlagOptions.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index cf7e747c5..5baf3f620 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -228,7 +228,7 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) { - throw new ArgumentException($"Tag '{tag}' does not follow the format \"tag=value\" or \"tag=\".", nameof(tagsFilter)); + throw new ArgumentException($"Tag '{tag}' does not follow the format \"tag=value\".", nameof(tagsFilter)); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index e79f9e447..442933a9e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -109,7 +109,7 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) { - throw new ArgumentException($"Tag '{tag}' does not follow the format \"tag=value\" or \"tag=\".", nameof(tagsFilter)); + throw new ArgumentException($"Tag '{tag}' does not follow the format \"tag=value\".", nameof(tagsFilter)); } } } From 6b0aaa4f5b3d9a16147c04c23de1141d9b24b397 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 10:38:47 -0700 Subject: [PATCH 20/48] edit comment --- .../AzureAppConfigurationOptions.cs | 2 +- .../FeatureManagement/FeatureFlagOptions.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 5baf3f620..2a656ce58 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -200,7 +200,7 @@ public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory /// /// The tag filter to apply when querying Azure App Configuration for key-values. By default no tags will be used. - /// Each tag provided must follow the format "tag=value". A key-value will only be returned if its tags match all tags provided + /// Each tag provided must follow the format "tag=value". A key-value will only be returned if its tags contain all tags provided /// in the filter, or if the filter is empty. /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). /// diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 442933a9e..1f13d4c91 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -76,7 +76,7 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) /// /// /// The tag filter to apply when querying Azure App Configuration for key-values. By default no tags will be used. - /// Each tag provided must follow the format "tag=value". A key-value will only be returned if its tags match all tags provided + /// Each tag provided must follow the format "tag=value". A key-value will only be returned if its tags contain all tags provided /// in the filter, or if the filter is empty. /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). /// From c243aa82174336671c226cf20a1ea45145971f4e Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 12:08:41 -0700 Subject: [PATCH 21/48] add unit tests --- .../Integration/appsettings.Secrets.json | 4 + .../TagsFilterTests.cs | 269 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 tests/Tests.AzureAppConfiguration/Integration/appsettings.Secrets.json create mode 100644 tests/Tests.AzureAppConfiguration/TagsFilterTests.cs diff --git a/tests/Tests.AzureAppConfiguration/Integration/appsettings.Secrets.json b/tests/Tests.AzureAppConfiguration/Integration/appsettings.Secrets.json new file mode 100644 index 000000000..221a4e405 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/Integration/appsettings.Secrets.json @@ -0,0 +1,4 @@ +{ + "SubscriptionId": "77d5ab06-7edd-4eec-bce9-52c11b75bb37", + "Success": true +} diff --git a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs new file mode 100644 index 000000000..3047c1d82 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Moq; +using System; +using System.Collections.Generic; +using System.Threading; +using Xunit; + +namespace Tests.AzureAppConfiguration +{ + public class TagsFilterTests + { + private List _kvCollection; + + public TagsFilterTests() + { + _kvCollection = new List + { + CreateConfigurationSetting("TestKey1", "label", "TestValue1", "0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63", + new Dictionary { { "Environment", "Development" }, { "App", "TestApp" } }), + + CreateConfigurationSetting("TestKey2", "label", "TestValue2", "31c38369-831f-4bf1-b9ad-79db56c8b989", + new Dictionary { { "Environment", "Production" }, { "App", "TestApp" } }), + + CreateConfigurationSetting("TestKey3", "label", "TestValue3", "bb203f2b-c113-44fc-995d-b933c2143339", + new Dictionary { { "Environment", "Development" }, { "Component", "API" } }), + + CreateConfigurationSetting("TestKey4", "label", "TestValue4", "bb203f2b-c113-44fc-995d-b933c2143340", + new Dictionary { { "Environment", "Staging" }, { "App", "TestApp" }, { "Component", "Frontend" } }), + + CreateConfigurationSetting("TestKey5", "label", "TestValue5", "bb203f2b-c113-44fc-995d-b933c2143341", + new Dictionary { { "Special:Tag", "Value:With:Colons" }, { "Tag@With@At", "Value@With@At" } }), + + CreateConfigurationSetting("TestKey6", "label", "TestValue6", "bb203f2b-c113-44fc-995d-b933c2143342", + new Dictionary { { "Tag,With,Commas", "Value,With,Commas" }, { "Simple", "Tag" } }) + }; + } + + private ConfigurationSetting CreateConfigurationSetting(string key, string label, string value, string etag, IDictionary tags) + { + // Create the setting without tags + var setting = ConfigurationModelFactory.ConfigurationSetting( + key: key, + label: label, + value: value, + eTag: new ETag(etag), + contentType: "text"); + + // Add tags to the setting + if (tags != null) + { + foreach (var tag in tags) + { + setting.Tags.Add(tag.Key, tag.Value); + } + } + + return setting; + } + + [Fact] + public void TagsFilterTests_BasicTagFiltering() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains("Environment=Development")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + }) + .Build(); + + // Only TestKey1 and TestKey3 have Environment=Development tag + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + } + + [Fact] + public void TagsFilterTests_MultipleTagsFiltering() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains("App=TestApp") && + s.TagsFilter.Contains("Environment=")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("App") && kv.Tags["App"] == "TestApp" && + kv.Tags.ContainsKey("Environment")))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "App=TestApp", "Environment=" }); + }) + .Build(); + + // TestKey1, TestKey2, and TestKey4 have App=TestApp tag and have Environment tag + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Equal("TestValue2", config["TestKey2"]); + Assert.Equal("TestValue4", config["TestKey4"]); + Assert.Null(config["TestKey3"]); // Has Environment tag but not App=TestApp + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + } + + [Fact] + public void TagsFilterTests_InvalidTagFormat() + { + var mockClient = new Mock(MockBehavior.Strict); + + // Verify that an ArgumentException is thrown when using an invalid tag format + var exception = Assert.Throws(() => + { + new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "InvalidTagFormat" }); + }) + .Build(); + }); + + Assert.Contains($"Tag 'InvalidTagFormat' does not follow the format \"tag=value\".", exception.Message); + } + + [Fact] + public void TagsFilterTests_TagFilterInteractionWithKeyLabelFilters() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + // Setup mock to verify that all three filters (key, label, tags) are correctly applied together + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.KeyFilter == "TestKey*" && + s.LabelFilter == "label" && + s.TagsFilter.Contains("Environment=Development")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Key.StartsWith("TestKey") && + kv.Label == "label" && + kv.Tags.ContainsKey("Environment") && + kv.Tags["Environment"] == "Development"))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select("TestKey*", "label", new List { "Environment=Development" }); + }) + .Build(); + + // Only TestKey1 and TestKey3 match all criteria + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + } + + [Fact] + public void TagsFilterTests_EmptyTagsCollection() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + // Setup mock to verify behavior with empty tags collection + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Count == 0), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List()); + }) + .Build(); + + // All keys should be returned when no tag filtering is applied + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Equal("TestValue2", config["TestKey2"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Equal("TestValue4", config["TestKey4"]); + Assert.Equal("TestValue5", config["TestKey5"]); + } + + [Fact] + public void TagsFilterTests_SpecialCharactersInTags() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + // Setup mock for special characters in tags + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains("Special:Tag=Value:With:Colons")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("Special:Tag") && kv.Tags["Special:Tag"] == "Value:With:Colons"))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "Special:Tag=Value:With:Colons" }); + }) + .Build(); + + // Only TestKey5 has the special character tag + Assert.Equal("TestValue5", config["TestKey5"]); + Assert.Null(config["TestKey1"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey3"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey6"]); + } + + [Fact] + public void TagsFilterTests_EscapedCommaCharactersInTags() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + // Setup mock for comma characters in tags that need to be escaped with backslash + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains(@"Tag\,With\,Commas=Value\,With\,Commas")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("Tag,With,Commas") && kv.Tags["Tag,With,Commas"] == "Value,With,Commas"))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { @"Tag\,With\,Commas=Value\,With\,Commas" }); + }) + .Build(); + + // Only TestKey6 has the tag with commas + Assert.Equal("TestValue6", config["TestKey6"]); + Assert.Null(config["TestKey1"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey3"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + } + } +} From b50529e6391253ae0f8c899d5e833f6f170b4f3a Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 12:09:20 -0700 Subject: [PATCH 22/48] remove unused file --- .../Integration/appsettings.Secrets.json | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 tests/Tests.AzureAppConfiguration/Integration/appsettings.Secrets.json diff --git a/tests/Tests.AzureAppConfiguration/Integration/appsettings.Secrets.json b/tests/Tests.AzureAppConfiguration/Integration/appsettings.Secrets.json deleted file mode 100644 index 221a4e405..000000000 --- a/tests/Tests.AzureAppConfiguration/Integration/appsettings.Secrets.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "SubscriptionId": "77d5ab06-7edd-4eec-bce9-52c11b75bb37", - "Success": true -} From c2e3558e5dd1eb27fcfb44aec15e8486ad8dc50c Mon Sep 17 00:00:00 2001 From: Sami Sadfa Date: Tue, 18 Mar 2025 12:45:53 -0700 Subject: [PATCH 23/48] revert versions --- .../Microsoft.Azure.AppConfiguration.AspNetCore.csproj | 2 +- .../Microsoft.Azure.AppConfiguration.Functions.Worker.csproj | 2 +- ...rosoft.Extensions.Configuration.AzureAppConfiguration.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj index 4cd6bf4e7..a5d4dee4e 100644 --- a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj +++ b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj @@ -21,7 +21,7 @@ - 8.1.1 + 8.1.0-preview diff --git a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj index e327421b7..4354b77ec 100644 --- a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj +++ b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj @@ -24,7 +24,7 @@ - 8.1.1 + 8.1.0-preview diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index 5b941baac..369f5dbdf 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -36,7 +36,7 @@ - 8.1.1 + 8.1.0-preview From a7e3a69de52eb1700ff7df90606d99b2ccfe60a3 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 12:52:08 -0700 Subject: [PATCH 24/48] update tests to include feature flag select --- .../TagsFilterTests.cs | 123 +++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs index 3047c1d82..27ebd56f5 100644 --- a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs +++ b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs @@ -5,6 +5,7 @@ using Azure.Data.AppConfiguration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; using Moq; using System; using System.Collections.Generic; @@ -16,6 +17,7 @@ namespace Tests.AzureAppConfiguration public class TagsFilterTests { private List _kvCollection; + private List _ffCollection; public TagsFilterTests() { @@ -37,6 +39,24 @@ public TagsFilterTests() new Dictionary { { "Special:Tag", "Value:With:Colons" }, { "Tag@With@At", "Value@With@At" } }), CreateConfigurationSetting("TestKey6", "label", "TestValue6", "bb203f2b-c113-44fc-995d-b933c2143342", + new Dictionary { { "Tag,With,Commas", "Value,With,Commas" }, { "Simple", "Tag" } }), + + CreateFeatureFlagSetting("Feature1", "label", true, "0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63", + new Dictionary { { "Environment", "Development" }, { "App", "TestApp" } }), + + CreateFeatureFlagSetting("Feature2", "label", false, "31c38369-831f-4bf1-b9ad-79db56c8b989", + new Dictionary { { "Environment", "Production" }, { "App", "TestApp" } }), + + CreateFeatureFlagSetting("Feature3", "label", true, "bb203f2b-c113-44fc-995d-b933c2143339", + new Dictionary { { "Environment", "Development" }, { "Component", "API" } }), + + CreateFeatureFlagSetting("Feature4", "label", false, "bb203f2b-c113-44fc-995d-b933c2143340", + new Dictionary { { "Environment", "Staging" }, { "App", "TestApp" }, { "Component", "Frontend" } }), + + CreateFeatureFlagSetting("Feature5", "label", true, "bb203f2b-c113-44fc-995d-b933c2143341", + new Dictionary { { "Special:Tag", "Value:With:Colons" }, { "Tag@With@At", "Value@With@At" } }), + + CreateFeatureFlagSetting("Feature6", "label", false, "bb203f2b-c113-44fc-995d-b933c2143342", new Dictionary { { "Tag,With,Commas", "Value,With,Commas" }, { "Simple", "Tag" } }) }; } @@ -63,6 +83,38 @@ private ConfigurationSetting CreateConfigurationSetting(string key, string label return setting; } + private ConfigurationSetting CreateFeatureFlagSetting(string featureId, string label, bool enabled, string etag, IDictionary tags) + { + string jsonValue = $@" + {{ + ""id"": ""{featureId}"", + ""description"": ""Test feature flag"", + ""enabled"": {enabled.ToString().ToLowerInvariant()}, + ""conditions"": {{ + ""client_filters"": [] + }} + }}"; + + // Create the feature flag setting + var setting = ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + featureId, + label: label, + value: jsonValue, + eTag: new ETag(etag), + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8"); + + // Add tags to the setting + if (tags != null) + { + foreach (var tag in tags) + { + setting.Tags.Add(tag.Key, tag.Value); + } + } + + return setting; + } + [Fact] public void TagsFilterTests_BasicTagFiltering() { @@ -80,6 +132,10 @@ public void TagsFilterTests_BasicTagFiltering() { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + }); }) .Build(); @@ -90,6 +146,13 @@ public void TagsFilterTests_BasicTagFiltering() Assert.Null(config["TestKey4"]); Assert.Null(config["TestKey5"]); Assert.Null(config["TestKey6"]); + + Assert.NotNull(config["FeatureManagement:Feature1"]); + Assert.NotNull(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); } [Fact] @@ -111,6 +174,10 @@ public void TagsFilterTests_MultipleTagsFiltering() { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.Select(KeyFilter.Any, "label", new List { "App=TestApp", "Environment=" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "App=TestApp", "Environment=" }); + }); }) .Build(); @@ -121,6 +188,13 @@ public void TagsFilterTests_MultipleTagsFiltering() Assert.Null(config["TestKey3"]); // Has Environment tag but not App=TestApp Assert.Null(config["TestKey5"]); Assert.Null(config["TestKey6"]); + + Assert.NotNull(config["FeatureManagement:Feature1"]); + Assert.NotNull(config["FeatureManagement:Feature2"]); + Assert.NotNull(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); } [Fact] @@ -151,12 +225,12 @@ public void TagsFilterTests_TagFilterInteractionWithKeyLabelFilters() // Setup mock to verify that all three filters (key, label, tags) are correctly applied together mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => - s.KeyFilter == "TestKey*" && + (s.KeyFilter == "TestKey*" || s.KeyFilter == FeatureManagementConstants.FeatureFlagMarker + "Feature1") && s.LabelFilter == "label" && s.TagsFilter.Contains("Environment=Development")), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => - kv.Key.StartsWith("TestKey") && + (kv.Key.StartsWith("TestKey") || kv.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker + "Feature1")) && kv.Label == "label" && kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))); @@ -166,6 +240,10 @@ public void TagsFilterTests_TagFilterInteractionWithKeyLabelFilters() { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.Select("TestKey*", "label", new List { "Environment=Development" }); + options.UseFeatureFlags(ff => + { + ff.Select("Feature1", "label", new List { "Environment=Development" }); + }); }) .Build(); @@ -176,6 +254,13 @@ public void TagsFilterTests_TagFilterInteractionWithKeyLabelFilters() Assert.Null(config["TestKey4"]); Assert.Null(config["TestKey5"]); Assert.Null(config["TestKey6"]); + + Assert.NotNull(config["FeatureManagement:Feature1"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); } [Fact] @@ -195,6 +280,10 @@ public void TagsFilterTests_EmptyTagsCollection() { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.Select(KeyFilter.Any, "label", new List()); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List()); + }); }) .Build(); @@ -204,6 +293,14 @@ public void TagsFilterTests_EmptyTagsCollection() Assert.Equal("TestValue3", config["TestKey3"]); Assert.Equal("TestValue4", config["TestKey4"]); Assert.Equal("TestValue5", config["TestKey5"]); + Assert.Equal("TestValue6", config["TestKey6"]); + + Assert.NotNull(config["FeatureManagement:Feature1"]); + Assert.NotNull(config["FeatureManagement:Feature2"]); + Assert.NotNull(config["FeatureManagement:Feature3"]); + Assert.NotNull(config["FeatureManagement:Feature4"]); + Assert.NotNull(config["FeatureManagement:Feature5"]); + Assert.NotNull(config["FeatureManagement:Feature6"]); } [Fact] @@ -224,6 +321,10 @@ public void TagsFilterTests_SpecialCharactersInTags() { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.Select(KeyFilter.Any, "label", new List { "Special:Tag=Value:With:Colons" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "Special:Tag=Value:With:Colons" }); + }); }) .Build(); @@ -234,6 +335,13 @@ public void TagsFilterTests_SpecialCharactersInTags() Assert.Null(config["TestKey3"]); Assert.Null(config["TestKey4"]); Assert.Null(config["TestKey6"]); + + Assert.NotNull(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature1"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature6"]); } [Fact] @@ -254,6 +362,10 @@ public void TagsFilterTests_EscapedCommaCharactersInTags() { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.Select(KeyFilter.Any, "label", new List { @"Tag\,With\,Commas=Value\,With\,Commas" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { @"Tag\,With\,Commas=Value\,With\,Commas" }); + }); }) .Build(); @@ -264,6 +376,13 @@ public void TagsFilterTests_EscapedCommaCharactersInTags() Assert.Null(config["TestKey3"]); Assert.Null(config["TestKey4"]); Assert.Null(config["TestKey5"]); + + Assert.NotNull(config["FeatureManagement:Feature6"]); + Assert.Null(config["FeatureManagement:Feature1"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); } } } From 959d7af59cc1343102b3133c29d8e8f6dc05f767 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 13:18:12 -0700 Subject: [PATCH 25/48] add refresh test --- .../TagsFilterTests.cs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs index 27ebd56f5..5a2121bac 100644 --- a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs +++ b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs @@ -10,6 +10,7 @@ using System; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; using Xunit; namespace Tests.AzureAppConfiguration @@ -384,5 +385,85 @@ public void TagsFilterTests_EscapedCommaCharactersInTags() Assert.Null(config["FeatureManagement:Feature4"]); Assert.Null(config["FeatureManagement:Feature5"]); } + + [Fact] + public async Task TagsFilterTests_BasicRefresh() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + IConfigurationRefresher refresher = null; + + var mockAsyncPageable = new MockAsyncPageable(_kvCollection); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Callback(() => mockAsyncPageable.UpdateCollection(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))) + .Returns(mockAsyncPageable); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.RegisterAll(); + refreshOptions.SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + ff.SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + // Only TestKey1 and TestKey3 have Environment=Development tag + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + + Assert.Equal("True", config["FeatureManagement:Feature1"]); + Assert.NotNull(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); + + _kvCollection.Find(setting => setting.Key == "TestKey1").Value = "UpdatedValue1"; + + _kvCollection.Find(setting => setting.Key == FeatureManagementConstants.FeatureFlagMarker + "Feature1").Value = $@" + {{ + ""id"": ""Feature1"", + ""description"": ""Test feature flag"", + ""enabled"": false, + ""conditions"": {{ + ""client_filters"": [] + }} + }}"; + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("UpdatedValue1", config["TestKey1"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + + Assert.Equal("False", config["FeatureManagement:Feature1"]); + Assert.NotNull(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); + } } } From 1fc066be20f0a89cea7696072eef010c7f4da4bc Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 13:19:13 -0700 Subject: [PATCH 26/48] ff only refresh test --- .../TagsFilterTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs index 5a2121bac..13905c215 100644 --- a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs +++ b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs @@ -464,6 +464,34 @@ public async Task TagsFilterTests_BasicRefresh() Assert.Null(config["FeatureManagement:Feature4"]); Assert.Null(config["FeatureManagement:Feature5"]); Assert.Null(config["FeatureManagement:Feature6"]); + + _kvCollection.Find(setting => setting.Key == FeatureManagementConstants.FeatureFlagMarker + "Feature1").Value = $@" + {{ + ""id"": ""Feature1"", + ""description"": ""Test feature flag"", + ""enabled"": true, + ""conditions"": {{ + ""client_filters"": [] + }} + }}"; + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("UpdatedValue1", config["TestKey1"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + + Assert.Equal("True", config["FeatureManagement:Feature1"]); + Assert.NotNull(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); } } } From d8a421a5d7c6bd90f69e7d47fe37683df878f092 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 13:30:28 -0700 Subject: [PATCH 27/48] update equals for selector --- .../Models/KeyValueSelector.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index ef7b965d5..fefa0b6c3 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // using System.Collections.Generic; +using System.Linq; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Models { @@ -48,7 +49,7 @@ public override bool Equals(object obj) return KeyFilter == selector.KeyFilter && LabelFilter == selector.LabelFilter && SnapshotName == selector.SnapshotName - && TagsFilter == selector.TagsFilter; + && (TagsFilter?.SequenceEqual(selector.TagsFilter) ?? selector.TagsFilter == null); } return false; From eee46a4599fcdcc281b26fa2243f539de72b12e8 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 13:37:45 -0700 Subject: [PATCH 28/48] fix equals --- .../Models/KeyValueSelector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index fefa0b6c3..070c91dd0 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -49,7 +49,7 @@ public override bool Equals(object obj) return KeyFilter == selector.KeyFilter && LabelFilter == selector.LabelFilter && SnapshotName == selector.SnapshotName - && (TagsFilter?.SequenceEqual(selector.TagsFilter) ?? selector.TagsFilter == null); + && TagsFilter != null ? new HashSet(TagsFilter).SetEquals(selector.TagsFilter) : selector.TagsFilter == null; } return false; From da49c3205cf7ce8963f9ccfc3300506193a766c3 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 13:41:22 -0700 Subject: [PATCH 29/48] update equals --- .../Models/KeyValueSelector.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 070c91dd0..9fb13acec 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -49,7 +49,8 @@ public override bool Equals(object obj) return KeyFilter == selector.KeyFilter && LabelFilter == selector.LabelFilter && SnapshotName == selector.SnapshotName - && TagsFilter != null ? new HashSet(TagsFilter).SetEquals(selector.TagsFilter) : selector.TagsFilter == null; + && TagsFilter != null ? new HashSet(TagsFilter).SetEquals(selector.TagsFilter) : selector.TagsFilter == null + && IsFeatureFlagSelector == selector.IsFeatureFlagSelector; } return false; @@ -64,7 +65,8 @@ public override int GetHashCode() return (KeyFilter?.GetHashCode() ?? 0) ^ (LabelFilter?.GetHashCode() ?? 1) ^ (SnapshotName?.GetHashCode() ?? 2) ^ - (TagsFilter?.GetHashCode() ?? 3); + (TagsFilter?.GetHashCode() ?? 3) ^ + (IsFeatureFlagSelector.GetHashCode()); } } } From c3168a97dd2535ed30d64b852f63b9ed4b29dc0e Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 13:43:24 -0700 Subject: [PATCH 30/48] reorder properties in keyvalueselector --- .../Models/KeyValueSelector.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 9fb13acec..15e789666 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -23,14 +23,14 @@ public class KeyValueSelector public string LabelFilter { get; set; } /// - /// A filter that determines what tags to require when selecting key-values for the the configuration provider. + /// The name of the Azure App Configuration snapshot to use when selecting key-values for the configuration provider. /// - public IEnumerable TagsFilter { get; set; } + public string SnapshotName { get; set; } /// - /// The name of the Azure App Configuration snapshot to use when selecting key-values for the configuration provider. + /// A filter that determines what tags to require when selecting key-values for the the configuration provider. /// - public string SnapshotName { get; set; } + public IEnumerable TagsFilter { get; set; } /// /// A boolean that signifies whether this selector is intended to select feature flags. From d7f5939c7fe1038c558b10561ea61af06ed768fb Mon Sep 17 00:00:00 2001 From: Sami Sadfa <71456174+samsadsam@users.noreply.github.com> Date: Tue, 18 Mar 2025 17:18:35 -0400 Subject: [PATCH 31/48] upgrade to 8.2.0-preview (#638) --- .../Microsoft.Azure.AppConfiguration.AspNetCore.csproj | 2 +- .../Microsoft.Azure.AppConfiguration.Functions.Worker.csproj | 2 +- ...rosoft.Extensions.Configuration.AzureAppConfiguration.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj index a5d4dee4e..fda406b99 100644 --- a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj +++ b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj @@ -21,7 +21,7 @@ - 8.1.0-preview + 8.2.0-preview diff --git a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj index 4354b77ec..eb43831eb 100644 --- a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj +++ b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj @@ -24,7 +24,7 @@ - 8.1.0-preview + 8.2.0-preview diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index 369f5dbdf..dc558788c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -36,7 +36,7 @@ - 8.1.0-preview + 8.2.0-preview From 54c8a4482999f0f4ab2e46a3984fac95c16ddbe7 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 15:00:16 -0700 Subject: [PATCH 32/48] fix incorrect test --- .../TagsFilterTests.cs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs index 13905c215..a84180a37 100644 --- a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs +++ b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs @@ -164,36 +164,35 @@ public void TagsFilterTests_MultipleTagsFiltering() mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => s.TagsFilter.Contains("App=TestApp") && - s.TagsFilter.Contains("Environment=")), + s.TagsFilter.Contains("Environment=Development")), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => kv.Tags.ContainsKey("App") && kv.Tags["App"] == "TestApp" && - kv.Tags.ContainsKey("Environment")))); + kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.Select(KeyFilter.Any, "label", new List { "App=TestApp", "Environment=" }); + options.Select(KeyFilter.Any, "label", new List { "App=TestApp", "Environment=Development" }); options.UseFeatureFlags(ff => { - ff.Select(KeyFilter.Any, "label", new List { "App=TestApp", "Environment=" }); + ff.Select(KeyFilter.Any, "label", new List { "App=TestApp", "Environment=Development" }); }); }) .Build(); - // TestKey1, TestKey2, and TestKey4 have App=TestApp tag and have Environment tag Assert.Equal("TestValue1", config["TestKey1"]); - Assert.Equal("TestValue2", config["TestKey2"]); - Assert.Equal("TestValue4", config["TestKey4"]); - Assert.Null(config["TestKey3"]); // Has Environment tag but not App=TestApp + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey3"]); + Assert.Null(config["TestKey4"]); Assert.Null(config["TestKey5"]); Assert.Null(config["TestKey6"]); Assert.NotNull(config["FeatureManagement:Feature1"]); - Assert.NotNull(config["FeatureManagement:Feature2"]); - Assert.NotNull(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature2"]); Assert.Null(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature4"]); Assert.Null(config["FeatureManagement:Feature5"]); Assert.Null(config["FeatureManagement:Feature6"]); } From 7661b2cbb03dd6925ac7356a73e311b892629b97 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 18 Mar 2025 16:18:39 -0700 Subject: [PATCH 33/48] fix equals for selector --- .../Models/KeyValueSelector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 15e789666..e6259ec9c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -49,7 +49,7 @@ public override bool Equals(object obj) return KeyFilter == selector.KeyFilter && LabelFilter == selector.LabelFilter && SnapshotName == selector.SnapshotName - && TagsFilter != null ? new HashSet(TagsFilter).SetEquals(selector.TagsFilter) : selector.TagsFilter == null + && (TagsFilter != null ? new HashSet(TagsFilter).SetEquals(selector.TagsFilter) : selector.TagsFilter == null) && IsFeatureFlagSelector == selector.IsFeatureFlagSelector; } From d108f64abf5c3c1da597645f1861616dcc4c37b2 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Thu, 20 Mar 2025 11:24:01 -0700 Subject: [PATCH 34/48] update gethashcode for keyvalueselector --- .../Models/KeyValueSelector.cs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index e6259ec9c..f76a22ab8 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -62,10 +62,32 @@ public override bool Equals(object obj) /// A hash code for the current object. public override int GetHashCode() { + int tagsFilterHash = 3; + + if (TagsFilter != null && TagsFilter.Any()) + { + var sortedTags = new SortedSet(TagsFilter); + + if (sortedTags.Any()) + { + // Concatenate tags into a single string with a delimiter + string tagsString = string.Join("|", sortedTags); + + // Use SHA256 to generate a hash for the tags + using (var sha256 = System.Security.Cryptography.SHA256.Create()) + { + byte[] hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(tagsString)); + + // Convert the first 4 bytes of the hash to an int + tagsFilterHash = System.BitConverter.ToInt32(hashBytes, 0); + } + } + } + return (KeyFilter?.GetHashCode() ?? 0) ^ (LabelFilter?.GetHashCode() ?? 1) ^ (SnapshotName?.GetHashCode() ?? 2) ^ - (TagsFilter?.GetHashCode() ?? 3) ^ + tagsFilterHash ^ (IsFeatureFlagSelector.GetHashCode()); } } From f2191df1da42779227a76f9ec4726ce216426a4c Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 25 Mar 2025 11:55:43 -0700 Subject: [PATCH 35/48] PR comments, in progress --- .../AzureAppConfigurationOptions.cs | 10 ++++--- .../AzureAppConfigurationProvider.cs | 1 + ...Configuration.AzureAppConfiguration.csproj | 1 + .../Models/KeyValueSelector.cs | 30 +++++++------------ .../Models/KeyValueWatcher.cs | 7 +++++ .../TagValue.cs | 16 ++++++++++ .../TagsFilterTests.cs | 2 +- 7 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 7ef8c80fd..b1f82cceb 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -203,10 +203,11 @@ public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory /// - /// The tag filter to apply when querying Azure App Configuration for key-values. By default no tags will be used. - /// Each tag provided must follow the format "tag=value". A key-value will only be returned if its tags contain all tags provided - /// in the filter, or if the filter is empty. + /// In addition to key and label filters, key-values from Azure App Configuration can be filtered based on their tag names and values. + /// Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. + /// Built in tag filter values: . For example, $"tagName={}". /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). + /// Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. /// public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilter = null) { @@ -232,7 +233,7 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) { - throw new ArgumentException($"Tag '{tag}' does not follow the format \"tag=value\".", nameof(tagsFilter)); + throw new ArgumentException($"Tag '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagsFilter)); } } } @@ -322,6 +323,7 @@ public AzureAppConfigurationOptions UseFeatureFlags(Action c { Key = featureFlagSelector.KeyFilter, Label = featureFlagSelector.LabelFilter, + Tags = featureFlagSelector.TagsFilter, // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins RefreshInterval = options.RefreshInterval }); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 0d66ab6e9..c70dbf79a 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -344,6 +344,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { KeyFilter = watcher.Key, LabelFilter = watcher.Label, + TagsFilter = watcher.Tags, IsFeatureFlagSelector = true }), _ffEtags, diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index 492bd714c..7aeffafe6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index f76a22ab8..a69e6dd41 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System; using System.Collections.Generic; using System.Linq; @@ -62,33 +63,24 @@ public override bool Equals(object obj) /// A hash code for the current object. public override int GetHashCode() { - int tagsFilterHash = 3; + int tagsFilterHashCode = 3; if (TagsFilter != null && TagsFilter.Any()) { var sortedTags = new SortedSet(TagsFilter); - if (sortedTags.Any()) - { - // Concatenate tags into a single string with a delimiter - string tagsString = string.Join("|", sortedTags); + // Concatenate tags into a single string with a delimiter + string tagsString = string.Join("|", sortedTags); - // Use SHA256 to generate a hash for the tags - using (var sha256 = System.Security.Cryptography.SHA256.Create()) - { - byte[] hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(tagsString)); - - // Convert the first 4 bytes of the hash to an int - tagsFilterHash = System.BitConverter.ToInt32(hashBytes, 0); - } - } + tagsFilterHashCode = tagsString.GetHashCode(); } - return (KeyFilter?.GetHashCode() ?? 0) ^ - (LabelFilter?.GetHashCode() ?? 1) ^ - (SnapshotName?.GetHashCode() ?? 2) ^ - tagsFilterHash ^ - (IsFeatureFlagSelector.GetHashCode()); + return HashCode.Combine( + KeyFilter?.GetHashCode() ?? 0, + LabelFilter?.GetHashCode() ?? 1, + SnapshotName?.GetHashCode() ?? 2, + tagsFilterHashCode, + IsFeatureFlagSelector.GetHashCode()); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs index 616f8bcd1..b3736de2d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs @@ -3,6 +3,8 @@ // using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using System; +using System.Collections; +using System.Collections.Generic; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Models { @@ -18,6 +20,11 @@ internal class KeyValueWatcher /// public string Label { get; set; } + /// + /// Tags of the key-value to be watched. + /// + public IEnumerable Tags { get; set; } + /// /// A flag to refresh all key-values. /// diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs new file mode 100644 index 000000000..9e339e36e --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + /// + /// Defines well known tag filter values that are used within Azure App Configuration. + /// + public class TagValue + { + /// + /// Matches null tag values. + /// + public const string Null = "\0"; + } +} diff --git a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs index a84180a37..68ce1efce 100644 --- a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs +++ b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs @@ -214,7 +214,7 @@ public void TagsFilterTests_InvalidTagFormat() .Build(); }); - Assert.Contains($"Tag 'InvalidTagFormat' does not follow the format \"tag=value\".", exception.Message); + Assert.Contains($"Tag 'InvalidTagFormat' does not follow the format \"tagName=tagValue\".", exception.Message); } [Fact] From 7387e287ddbbbcec5f99b60e3c3c5d2b95b37e18 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 25 Mar 2025 12:41:41 -0700 Subject: [PATCH 36/48] update tests from PR comments --- .../TagsFilterTests.cs | 137 +++++++++++++++--- 1 file changed, 113 insertions(+), 24 deletions(-) diff --git a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs index 68ce1efce..538c4ba17 100644 --- a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs +++ b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs @@ -25,40 +25,82 @@ public TagsFilterTests() _kvCollection = new List { CreateConfigurationSetting("TestKey1", "label", "TestValue1", "0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63", - new Dictionary { { "Environment", "Development" }, { "App", "TestApp" } }), + new Dictionary { + { "Environment", "Development" }, + { "App", "TestApp" } + }), CreateConfigurationSetting("TestKey2", "label", "TestValue2", "31c38369-831f-4bf1-b9ad-79db56c8b989", - new Dictionary { { "Environment", "Production" }, { "App", "TestApp" } }), + new Dictionary { + { "Environment", "Production" }, + { "App", "TestApp" } + }), CreateConfigurationSetting("TestKey3", "label", "TestValue3", "bb203f2b-c113-44fc-995d-b933c2143339", - new Dictionary { { "Environment", "Development" }, { "Component", "API" } }), + new Dictionary { + { "Environment", "Development" }, + { "Component", "API" } + }), CreateConfigurationSetting("TestKey4", "label", "TestValue4", "bb203f2b-c113-44fc-995d-b933c2143340", - new Dictionary { { "Environment", "Staging" }, { "App", "TestApp" }, { "Component", "Frontend" } }), + new Dictionary { + { "Environment", "Staging" }, + { "App", "TestApp" }, + { "Component", "Frontend" } + }), CreateConfigurationSetting("TestKey5", "label", "TestValue5", "bb203f2b-c113-44fc-995d-b933c2143341", - new Dictionary { { "Special:Tag", "Value:With:Colons" }, { "Tag@With@At", "Value@With@At" } }), + new Dictionary { + { "Special:Tag", "Value:With:Colons" }, + { "Tag@With@At", "Value@With@At" } + }), CreateConfigurationSetting("TestKey6", "label", "TestValue6", "bb203f2b-c113-44fc-995d-b933c2143342", - new Dictionary { { "Tag,With,Commas", "Value,With,Commas" }, { "Simple", "Tag" } }), + new Dictionary { + { "Tag,With,Commas", "Value,With,Commas" }, + { "Simple", "Tag" }, + { "EmptyTag", "" }, + { "NullTag", null } + }), CreateFeatureFlagSetting("Feature1", "label", true, "0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63", - new Dictionary { { "Environment", "Development" }, { "App", "TestApp" } }), + new Dictionary { + { "Environment", "Development" }, + { "App", "TestApp" } + }), CreateFeatureFlagSetting("Feature2", "label", false, "31c38369-831f-4bf1-b9ad-79db56c8b989", - new Dictionary { { "Environment", "Production" }, { "App", "TestApp" } }), + new Dictionary { + { "Environment", "Production" }, + { "App", "TestApp" } + }), CreateFeatureFlagSetting("Feature3", "label", true, "bb203f2b-c113-44fc-995d-b933c2143339", - new Dictionary { { "Environment", "Development" }, { "Component", "API" } }), + new Dictionary { + { "Environment", "Development" }, + { "Component", "API" } + }), CreateFeatureFlagSetting("Feature4", "label", false, "bb203f2b-c113-44fc-995d-b933c2143340", - new Dictionary { { "Environment", "Staging" }, { "App", "TestApp" }, { "Component", "Frontend" } }), + new Dictionary { + { "Environment", "Staging" }, + { "App", "TestApp" }, + { "Component", "Frontend" } + }), CreateFeatureFlagSetting("Feature5", "label", true, "bb203f2b-c113-44fc-995d-b933c2143341", - new Dictionary { { "Special:Tag", "Value:With:Colons" }, { "Tag@With@At", "Value@With@At" } }), + new Dictionary { + { "Special:Tag", "Value:With:Colons" }, + { "Tag@With@At", "Value@With@At" } + }), CreateFeatureFlagSetting("Feature6", "label", false, "bb203f2b-c113-44fc-995d-b933c2143342", - new Dictionary { { "Tag,With,Commas", "Value,With,Commas" }, { "Simple", "Tag" } }) + new Dictionary { + { "Tag,With,Commas", "Value,With,Commas" }, + { "Simple", "Tag" }, + { "EmptyTag", "" }, + { "NullTag", null } + }), }; } @@ -156,6 +198,48 @@ public void TagsFilterTests_BasicTagFiltering() Assert.Null(config["FeatureManagement:Feature6"]); } + [Fact] + public void TagsFilterTests_NullOrEmptyValue() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains("EmptyTag=") && + s.TagsFilter.Contains($"NullTag={TagValue.Null}")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("EmptyTag") && kv.Tags["EmptyTag"] == "" && + kv.Tags.ContainsKey("NullTag") && kv.Tags["NullTag"] == null))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "EmptyTag=", $"NullTag={TagValue.Null}" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "EmptyTag=", $"NullTag={TagValue.Null}" }); + }); + }) + .Build(); + + // Only TestKey6 and Feature6 have EmptyTag and NullTag + Assert.Null(config["TestKey1"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey3"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Equal("TestValue6", config["TestKey6"]); + + Assert.Null(config["FeatureManagement:Feature1"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.NotNull(config["FeatureManagement:Feature6"]); + } + [Fact] public void TagsFilterTests_MultipleTagsFiltering() { @@ -202,19 +286,24 @@ public void TagsFilterTests_InvalidTagFormat() { var mockClient = new Mock(MockBehavior.Strict); - // Verify that an ArgumentException is thrown when using an invalid tag format - var exception = Assert.Throws(() => - { - new ConfigurationBuilder() - .AddAzureAppConfiguration(options => - { - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.Select(KeyFilter.Any, "label", new List { "InvalidTagFormat" }); - }) - .Build(); - }); + List invalidTagsFilters = new List { "InvalidTagFormat", "=tagValue", "", null }; - Assert.Contains($"Tag 'InvalidTagFormat' does not follow the format \"tagName=tagValue\".", exception.Message); + foreach (string tagsFilter in invalidTagsFilters) + { + // Verify that an ArgumentException is thrown when using an invalid tag format + var exception = Assert.Throws(() => + { + new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { tagsFilter }); + }) + .Build(); + }); + + Assert.Contains($"Tag '{tagsFilter}' does not follow the format \"tagName=tagValue\".", exception.Message); + } } [Fact] From 17c6562d0af64d0cb1b86c780eaf5dd245fd66cb Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Wed, 26 Mar 2025 10:44:32 -0700 Subject: [PATCH 37/48] add validation for number of tags, add test --- .../AzureAppConfigurationOptions.cs | 7 ++++++ .../TagsFilterTests.cs | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index b1f82cceb..92f1aa13b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Azure; using Azure.Core; using Azure.Core.Pipeline; using Azure.Data.AppConfiguration; @@ -24,6 +25,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration public class AzureAppConfigurationOptions { private const int MaxRetries = 2; + private const int MaxTagFilters = 5; private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); private static readonly TimeSpan NetworkTimeout = TimeSpan.FromSeconds(10); private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; @@ -229,6 +231,11 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter if (tagsFilter != null) { + if (tagsFilter.Count() > MaxTagFilters) + { + throw new ArgumentException($"Cannot provide more than {MaxTagFilters} tag filters.", nameof(tagsFilter)); + } + foreach (var tag in tagsFilter) { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) diff --git a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs index 538c4ba17..e2867607c 100644 --- a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs +++ b/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs @@ -306,6 +306,28 @@ public void TagsFilterTests_InvalidTagFormat() } } + [Fact] + public void TagsFilterTests_TooManyTags() + { + var mockClient = new Mock(MockBehavior.Strict); + + List longTagsFilter = new List { "T1=1", "T2=V2", "T3=V3", "T4=V4", "T5=V5", "T6=V6" }; + + // Verify that an ArgumentException is thrown when passing more than the allowed number of tags + var exception = Assert.Throws(() => + { + new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", longTagsFilter); + }) + .Build(); + }); + + Assert.Contains($"Cannot provide more than 5 tag filters.", exception.Message); + } + [Fact] public void TagsFilterTests_TagFilterInteractionWithKeyLabelFilters() { From e1930cda60128b0aa7f5b53e39ff951896fe58a7 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 1 Apr 2025 11:31:27 -0700 Subject: [PATCH 38/48] rename tagsFilter to tagsFilters everywhere --- .../AzureAppConfigurationOptions.cs | 20 ++---- .../AzureAppConfigurationProvider.cs | 6 +- .../FeatureManagement/FeatureFlagOptions.cs | 12 ++-- .../Models/KeyValueSelector.cs | 14 ++-- ...TagsFilterTests.cs => TagsFiltersTests.cs} | 69 ++++++++++++------- 5 files changed, 69 insertions(+), 52 deletions(-) rename tests/Tests.AzureAppConfiguration/{TagsFilterTests.cs => TagsFiltersTests.cs} (91%) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 92f1aa13b..019bc7a75 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -25,7 +25,6 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration public class AzureAppConfigurationOptions { private const int MaxRetries = 2; - private const int MaxTagFilters = 5; private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); private static readonly TimeSpan NetworkTimeout = TimeSpan.FromSeconds(10); private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; @@ -204,14 +203,14 @@ public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). /// - /// + /// /// In addition to key and label filters, key-values from Azure App Configuration can be filtered based on their tag names and values. /// Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. /// Built in tag filter values: . For example, $"tagName={}". /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). /// Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. /// - public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilter = null) + public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilters = null) { if (string.IsNullOrEmpty(keyFilter)) { @@ -229,18 +228,13 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter labelFilter = LabelFilter.Null; } - if (tagsFilter != null) + if (tagsFilters != null) { - if (tagsFilter.Count() > MaxTagFilters) - { - throw new ArgumentException($"Cannot provide more than {MaxTagFilters} tag filters.", nameof(tagsFilter)); - } - - foreach (var tag in tagsFilter) + foreach (var tag in tagsFilters) { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) { - throw new ArgumentException($"Tag '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagsFilter)); + throw new ArgumentException($"Tag '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagsFilters)); } } } @@ -256,7 +250,7 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter { KeyFilter = keyFilter, LabelFilter = labelFilter, - TagsFilter = tagsFilter + TagsFilters = tagsFilters }); return this; @@ -330,7 +324,7 @@ public AzureAppConfigurationOptions UseFeatureFlags(Action c { Key = featureFlagSelector.KeyFilter, Label = featureFlagSelector.LabelFilter, - Tags = featureFlagSelector.TagsFilter, + Tags = featureFlagSelector.TagsFilters, // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins RefreshInterval = options.RefreshInterval }); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index c70dbf79a..f18b10a5c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -344,7 +344,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { KeyFilter = watcher.Key, LabelFilter = watcher.Label, - TagsFilter = watcher.Tags, + TagsFilters = watcher.Tags, IsFeatureFlagSelector = true }), _ffEtags, @@ -815,9 +815,9 @@ private async Task> LoadSelected( LabelFilter = loadOption.LabelFilter }; - if (loadOption.TagsFilter != null) + if (loadOption.TagsFilters != null) { - foreach (string tagFilter in loadOption.TagsFilter) + foreach (string tagFilter in loadOption.TagsFilters) { selector.TagsFilter.Add(tagFilter); } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 1f13d4c91..71f6a01fe 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -74,13 +74,13 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) /// The label filter to apply when querying Azure App Configuration for feature flags. By default the null label will be used. Built-in label filter options: /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). /// - /// + /// /// The tag filter to apply when querying Azure App Configuration for key-values. By default no tags will be used. /// Each tag provided must follow the format "tag=value". A key-value will only be returned if its tags contain all tags provided /// in the filter, or if the filter is empty. /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). /// - public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilter = null) + public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilters = null) { if (string.IsNullOrEmpty(featureFlagFilter)) { @@ -103,13 +103,13 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); } - if (tagsFilter != null) + if (tagsFilters != null) { - foreach (var tag in tagsFilter) + foreach (var tag in tagsFilters) { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) { - throw new ArgumentException($"Tag '{tag}' does not follow the format \"tag=value\".", nameof(tagsFilter)); + throw new ArgumentException($"Tag '{tag}' does not follow the format \"tag=value\".", nameof(tagsFilters)); } } } @@ -120,7 +120,7 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = { KeyFilter = featureFlagPrefix, LabelFilter = labelFilter, - TagsFilter = tagsFilter, + TagsFilters = tagsFilters, IsFeatureFlagSelector = true }); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index a69e6dd41..10ecd3a1f 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -31,7 +31,7 @@ public class KeyValueSelector /// /// A filter that determines what tags to require when selecting key-values for the the configuration provider. /// - public IEnumerable TagsFilter { get; set; } + public IEnumerable TagsFilters { get; set; } /// /// A boolean that signifies whether this selector is intended to select feature flags. @@ -50,7 +50,7 @@ public override bool Equals(object obj) return KeyFilter == selector.KeyFilter && LabelFilter == selector.LabelFilter && SnapshotName == selector.SnapshotName - && (TagsFilter != null ? new HashSet(TagsFilter).SetEquals(selector.TagsFilter) : selector.TagsFilter == null) + && (TagsFilters != null ? new HashSet(TagsFilters).SetEquals(selector.TagsFilters) : selector.TagsFilters == null) && IsFeatureFlagSelector == selector.IsFeatureFlagSelector; } @@ -63,23 +63,23 @@ public override bool Equals(object obj) /// A hash code for the current object. public override int GetHashCode() { - int tagsFilterHashCode = 3; + int tagsFiltersHashCode = 3; - if (TagsFilter != null && TagsFilter.Any()) + if (TagsFilters != null && TagsFilters.Any()) { - var sortedTags = new SortedSet(TagsFilter); + var sortedTags = new SortedSet(TagsFilters); // Concatenate tags into a single string with a delimiter string tagsString = string.Join("|", sortedTags); - tagsFilterHashCode = tagsString.GetHashCode(); + tagsFiltersHashCode = tagsString.GetHashCode(); } return HashCode.Combine( KeyFilter?.GetHashCode() ?? 0, LabelFilter?.GetHashCode() ?? 1, SnapshotName?.GetHashCode() ?? 2, - tagsFilterHashCode, + tagsFiltersHashCode, IsFeatureFlagSelector.GetHashCode()); } } diff --git a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs b/tests/Tests.AzureAppConfiguration/TagsFiltersTests.cs similarity index 91% rename from tests/Tests.AzureAppConfiguration/TagsFilterTests.cs rename to tests/Tests.AzureAppConfiguration/TagsFiltersTests.cs index e2867607c..ba2d80cad 100644 --- a/tests/Tests.AzureAppConfiguration/TagsFilterTests.cs +++ b/tests/Tests.AzureAppConfiguration/TagsFiltersTests.cs @@ -19,6 +19,7 @@ public class TagsFilterTests { private List _kvCollection; private List _ffCollection; + private const int MaxTagsFilters = 5; public TagsFilterTests() { @@ -159,9 +160,8 @@ private ConfigurationSetting CreateFeatureFlagSetting(string featureId, string l } [Fact] - public void TagsFilterTests_BasicTagFiltering() + public void TagsFiltersTests_BasicTagFiltering() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => @@ -199,9 +199,8 @@ public void TagsFilterTests_BasicTagFiltering() } [Fact] - public void TagsFilterTests_NullOrEmptyValue() + public void TagsFiltersTests_NullOrEmptyValue() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => @@ -241,9 +240,8 @@ public void TagsFilterTests_NullOrEmptyValue() } [Fact] - public void TagsFilterTests_MultipleTagsFiltering() + public void TagsFiltersTests_MultipleTagsFiltering() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => @@ -282,7 +280,7 @@ public void TagsFilterTests_MultipleTagsFiltering() } [Fact] - public void TagsFilterTests_InvalidTagFormat() + public void TagsFiltersTests_InvalidTagFormat() { var mockClient = new Mock(MockBehavior.Strict); @@ -307,14 +305,46 @@ public void TagsFilterTests_InvalidTagFormat() } [Fact] - public void TagsFilterTests_TooManyTags() + public void TagsFiltersTests_TooManyTags() { var mockClient = new Mock(MockBehavior.Strict); + var mockResponse = new Mock(); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains("Environment=Development") && s.TagsFilter.Count <= MaxTagsFilters), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Count > MaxTagsFilters), + It.IsAny())) + .Throws(new RequestFailedException($"Invalid parameter TagsFilter. Maximum filters is {MaxTagsFilters}")); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + }); + }) + .Build(); - List longTagsFilter = new List { "T1=1", "T2=V2", "T3=V3", "T4=V4", "T5=V5", "T6=V6" }; + List longTagsFilter = new List + { + "Environment=Development", + "Environment=Development", + "Environment=Development", + "Environment=Development", + "Environment=Development", + "Environment=Development" + }; - // Verify that an ArgumentException is thrown when passing more than the allowed number of tags - var exception = Assert.Throws(() => + // Verify that a RequestFailedException is thrown when passing more than the allowed number of tags + var exception = Assert.Throws(() => { new ConfigurationBuilder() .AddAzureAppConfiguration(options => @@ -324,14 +354,11 @@ public void TagsFilterTests_TooManyTags() }) .Build(); }); - - Assert.Contains($"Cannot provide more than 5 tag filters.", exception.Message); } [Fact] - public void TagsFilterTests_TagFilterInteractionWithKeyLabelFilters() + public void TagsFiltersTests_TagFilterInteractionWithKeyLabelFilters() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); // Setup mock to verify that all three filters (key, label, tags) are correctly applied together @@ -375,9 +402,8 @@ public void TagsFilterTests_TagFilterInteractionWithKeyLabelFilters() } [Fact] - public void TagsFilterTests_EmptyTagsCollection() + public void TagsFiltersTests_EmptyTagsCollection() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); // Setup mock to verify behavior with empty tags collection @@ -415,9 +441,8 @@ public void TagsFilterTests_EmptyTagsCollection() } [Fact] - public void TagsFilterTests_SpecialCharactersInTags() + public void TagsFiltersTests_SpecialCharactersInTags() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); // Setup mock for special characters in tags @@ -456,9 +481,8 @@ public void TagsFilterTests_SpecialCharactersInTags() } [Fact] - public void TagsFilterTests_EscapedCommaCharactersInTags() + public void TagsFiltersTests_EscapedCommaCharactersInTags() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); // Setup mock for comma characters in tags that need to be escaped with backslash @@ -497,9 +521,8 @@ public void TagsFilterTests_EscapedCommaCharactersInTags() } [Fact] - public async Task TagsFilterTests_BasicRefresh() + public async Task TagsFiltersTests_BasicRefresh() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); IConfigurationRefresher refresher = null; From a3be95273266d818efd5804c844f54549d889286 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 1 Apr 2025 13:24:58 -0700 Subject: [PATCH 39/48] fix usings, missing updates to ffoptions --- .../AzureAppConfigurationOptions.cs | 3 +-- .../FeatureManagement/FeatureFlagOptions.cs | 4 ++-- .../Models/KeyValueWatcher.cs | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 019bc7a75..5bad9663a 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // -using Azure; using Azure.Core; using Azure.Core.Pipeline; using Azure.Data.AppConfiguration; @@ -234,7 +233,7 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) { - throw new ArgumentException($"Tag '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagsFilters)); + throw new ArgumentException($"Tag filter '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagsFilters)); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 71f6a01fe..1cc42cde5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -76,7 +76,7 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) /// /// /// The tag filter to apply when querying Azure App Configuration for key-values. By default no tags will be used. - /// Each tag provided must follow the format "tag=value". A key-value will only be returned if its tags contain all tags provided + /// Each tag provided must follow the format "tagName=tagValue". A key-value will only be returned if its tags contain all tags provided /// in the filter, or if the filter is empty. /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). /// @@ -109,7 +109,7 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) { - throw new ArgumentException($"Tag '{tag}' does not follow the format \"tag=value\".", nameof(tagsFilters)); + throw new ArgumentException($"Tag filter '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagsFilters)); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs index b3736de2d..a9f59e745 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs @@ -3,7 +3,6 @@ // using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using System; -using System.Collections; using System.Collections.Generic; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Models From 48be704fb68fc4ad304e8d975131fbb438ca5535 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 1 Apr 2025 13:25:47 -0700 Subject: [PATCH 40/48] update ffoptions select again --- .../FeatureManagement/FeatureFlagOptions.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 1cc42cde5..04fbba5e0 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -75,10 +75,11 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). /// /// - /// The tag filter to apply when querying Azure App Configuration for key-values. By default no tags will be used. - /// Each tag provided must follow the format "tagName=tagValue". A key-value will only be returned if its tags contain all tags provided - /// in the filter, or if the filter is empty. + /// In addition to key and label filters, key-values from Azure App Configuration can be filtered based on their tag names and values. + /// Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. + /// Built in tag filter values: . For example, $"tagName={}". /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). + /// Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. /// public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilters = null) { From 6b2e2a4ba0460169a1beed241377bb1896dd027a Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 1 Apr 2025 13:27:24 -0700 Subject: [PATCH 41/48] fix tests --- tests/Tests.AzureAppConfiguration/TagsFiltersTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Tests.AzureAppConfiguration/TagsFiltersTests.cs b/tests/Tests.AzureAppConfiguration/TagsFiltersTests.cs index ba2d80cad..651353ae8 100644 --- a/tests/Tests.AzureAppConfiguration/TagsFiltersTests.cs +++ b/tests/Tests.AzureAppConfiguration/TagsFiltersTests.cs @@ -15,13 +15,13 @@ namespace Tests.AzureAppConfiguration { - public class TagsFilterTests + public class TagsFiltersTests { private List _kvCollection; private List _ffCollection; private const int MaxTagsFilters = 5; - public TagsFilterTests() + public TagsFiltersTests() { _kvCollection = new List { @@ -300,7 +300,7 @@ public void TagsFiltersTests_InvalidTagFormat() .Build(); }); - Assert.Contains($"Tag '{tagsFilter}' does not follow the format \"tagName=tagValue\".", exception.Message); + Assert.Contains($"Tag filter '{tagsFilter}' does not follow the format \"tagName=tagValue\".", exception.Message); } } From d3d9261ffab38bf71a5be96f78397084352e8807 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Wed, 2 Apr 2025 11:29:57 -0700 Subject: [PATCH 42/48] update sdk version --- ...rosoft.Extensions.Configuration.AzureAppConfiguration.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index 7aeffafe6..c613bcb2a 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -15,7 +15,7 @@ - + From 76f73eafb04b721de1d1c6cf33e600ab9e34411d Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Mon, 7 Apr 2025 11:53:03 -0700 Subject: [PATCH 43/48] update tagsfilters to tagfilters --- .../AzureAppConfigurationOptions.cs | 14 ++++---- .../AzureAppConfigurationProvider.cs | 6 ++-- .../FeatureManagement/FeatureFlagOptions.cs | 12 +++---- .../Models/KeyValueSelector.cs | 8 ++--- ...TagsFiltersTests.cs => TagFiltersTests.cs} | 36 +++++++++---------- 5 files changed, 38 insertions(+), 38 deletions(-) rename tests/Tests.AzureAppConfiguration/{TagsFiltersTests.cs => TagFiltersTests.cs} (96%) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 5bad9663a..a6829b18f 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -202,14 +202,14 @@ public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). /// - /// + /// /// In addition to key and label filters, key-values from Azure App Configuration can be filtered based on their tag names and values. /// Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. /// Built in tag filter values: . For example, $"tagName={}". /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). /// Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. /// - public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilters = null) + public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable tagFilters = null) { if (string.IsNullOrEmpty(keyFilter)) { @@ -227,13 +227,13 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter labelFilter = LabelFilter.Null; } - if (tagsFilters != null) + if (tagFilters != null) { - foreach (var tag in tagsFilters) + foreach (var tag in tagFilters) { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) { - throw new ArgumentException($"Tag filter '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagsFilters)); + throw new ArgumentException($"Tag filter '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagFilters)); } } } @@ -249,7 +249,7 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter { KeyFilter = keyFilter, LabelFilter = labelFilter, - TagsFilters = tagsFilters + TagFilters = tagFilters }); return this; @@ -323,7 +323,7 @@ public AzureAppConfigurationOptions UseFeatureFlags(Action c { Key = featureFlagSelector.KeyFilter, Label = featureFlagSelector.LabelFilter, - Tags = featureFlagSelector.TagsFilters, + Tags = featureFlagSelector.TagFilters, // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins RefreshInterval = options.RefreshInterval }); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index f18b10a5c..5edb2a33e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -344,7 +344,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { KeyFilter = watcher.Key, LabelFilter = watcher.Label, - TagsFilters = watcher.Tags, + TagFilters = watcher.Tags, IsFeatureFlagSelector = true }), _ffEtags, @@ -815,9 +815,9 @@ private async Task> LoadSelected( LabelFilter = loadOption.LabelFilter }; - if (loadOption.TagsFilters != null) + if (loadOption.TagFilters != null) { - foreach (string tagFilter in loadOption.TagsFilters) + foreach (string tagFilter in loadOption.TagFilters) { selector.TagsFilter.Add(tagFilter); } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 04fbba5e0..61ba39790 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -74,14 +74,14 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) /// The label filter to apply when querying Azure App Configuration for feature flags. By default the null label will be used. Built-in label filter options: /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). /// - /// + /// /// In addition to key and label filters, key-values from Azure App Configuration can be filtered based on their tag names and values. /// Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. /// Built in tag filter values: . For example, $"tagName={}". /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). /// Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. /// - public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IEnumerable tagsFilters = null) + public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IEnumerable tagFilters = null) { if (string.IsNullOrEmpty(featureFlagFilter)) { @@ -104,13 +104,13 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); } - if (tagsFilters != null) + if (tagFilters != null) { - foreach (var tag in tagsFilters) + foreach (var tag in tagFilters) { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) { - throw new ArgumentException($"Tag filter '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagsFilters)); + throw new ArgumentException($"Tag filter '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagFilters)); } } } @@ -121,7 +121,7 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = { KeyFilter = featureFlagPrefix, LabelFilter = labelFilter, - TagsFilters = tagsFilters, + TagFilters = tagFilters, IsFeatureFlagSelector = true }); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 10ecd3a1f..883750abf 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -31,7 +31,7 @@ public class KeyValueSelector /// /// A filter that determines what tags to require when selecting key-values for the the configuration provider. /// - public IEnumerable TagsFilters { get; set; } + public IEnumerable TagFilters { get; set; } /// /// A boolean that signifies whether this selector is intended to select feature flags. @@ -50,7 +50,7 @@ public override bool Equals(object obj) return KeyFilter == selector.KeyFilter && LabelFilter == selector.LabelFilter && SnapshotName == selector.SnapshotName - && (TagsFilters != null ? new HashSet(TagsFilters).SetEquals(selector.TagsFilters) : selector.TagsFilters == null) + && (TagFilters != null ? new HashSet(TagFilters).SetEquals(selector.TagFilters) : selector.TagFilters == null) && IsFeatureFlagSelector == selector.IsFeatureFlagSelector; } @@ -65,9 +65,9 @@ public override int GetHashCode() { int tagsFiltersHashCode = 3; - if (TagsFilters != null && TagsFilters.Any()) + if (TagFilters != null && TagFilters.Any()) { - var sortedTags = new SortedSet(TagsFilters); + var sortedTags = new SortedSet(TagFilters); // Concatenate tags into a single string with a delimiter string tagsString = string.Join("|", sortedTags); diff --git a/tests/Tests.AzureAppConfiguration/TagsFiltersTests.cs b/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs similarity index 96% rename from tests/Tests.AzureAppConfiguration/TagsFiltersTests.cs rename to tests/Tests.AzureAppConfiguration/TagFiltersTests.cs index 651353ae8..97592ea5e 100644 --- a/tests/Tests.AzureAppConfiguration/TagsFiltersTests.cs +++ b/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs @@ -15,13 +15,13 @@ namespace Tests.AzureAppConfiguration { - public class TagsFiltersTests + public class TagFiltersTests { private List _kvCollection; private List _ffCollection; - private const int MaxTagsFilters = 5; + private const int MaxTagFilters = 5; - public TagsFiltersTests() + public TagFiltersTests() { _kvCollection = new List { @@ -160,7 +160,7 @@ private ConfigurationSetting CreateFeatureFlagSetting(string featureId, string l } [Fact] - public void TagsFiltersTests_BasicTagFiltering() + public void TagFiltersTests_BasicTagFiltering() { var mockClient = new Mock(MockBehavior.Strict); @@ -199,7 +199,7 @@ public void TagsFiltersTests_BasicTagFiltering() } [Fact] - public void TagsFiltersTests_NullOrEmptyValue() + public void TagFiltersTests_NullOrEmptyValue() { var mockClient = new Mock(MockBehavior.Strict); @@ -240,7 +240,7 @@ public void TagsFiltersTests_NullOrEmptyValue() } [Fact] - public void TagsFiltersTests_MultipleTagsFiltering() + public void TagFiltersTests_MultipleTagsFiltering() { var mockClient = new Mock(MockBehavior.Strict); @@ -280,13 +280,13 @@ public void TagsFiltersTests_MultipleTagsFiltering() } [Fact] - public void TagsFiltersTests_InvalidTagFormat() + public void TagFiltersTests_InvalidTagFormat() { var mockClient = new Mock(MockBehavior.Strict); - List invalidTagsFilters = new List { "InvalidTagFormat", "=tagValue", "", null }; + List invalidTagFilters = new List { "InvalidTagFormat", "=tagValue", "", null }; - foreach (string tagsFilter in invalidTagsFilters) + foreach (string tagsFilter in invalidTagFilters) { // Verify that an ArgumentException is thrown when using an invalid tag format var exception = Assert.Throws(() => @@ -305,21 +305,21 @@ public void TagsFiltersTests_InvalidTagFormat() } [Fact] - public void TagsFiltersTests_TooManyTags() + public void TagFiltersTests_TooManyTags() { var mockClient = new Mock(MockBehavior.Strict); var mockResponse = new Mock(); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => - s.TagsFilter.Contains("Environment=Development") && s.TagsFilter.Count <= MaxTagsFilters), + s.TagsFilter.Contains("Environment=Development") && s.TagsFilter.Count <= MaxTagFilters), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => - s.TagsFilter.Count > MaxTagsFilters), + s.TagsFilter.Count > MaxTagFilters), It.IsAny())) - .Throws(new RequestFailedException($"Invalid parameter TagsFilter. Maximum filters is {MaxTagsFilters}")); + .Throws(new RequestFailedException($"Invalid parameter TagsFilter. Maximum filters is {MaxTagFilters}")); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => @@ -357,7 +357,7 @@ public void TagsFiltersTests_TooManyTags() } [Fact] - public void TagsFiltersTests_TagFilterInteractionWithKeyLabelFilters() + public void TagFiltersTests_TagFilterInteractionWithKeyLabelFilters() { var mockClient = new Mock(MockBehavior.Strict); @@ -402,7 +402,7 @@ public void TagsFiltersTests_TagFilterInteractionWithKeyLabelFilters() } [Fact] - public void TagsFiltersTests_EmptyTagsCollection() + public void TagFiltersTests_EmptyTagsCollection() { var mockClient = new Mock(MockBehavior.Strict); @@ -441,7 +441,7 @@ public void TagsFiltersTests_EmptyTagsCollection() } [Fact] - public void TagsFiltersTests_SpecialCharactersInTags() + public void TagFiltersTests_SpecialCharactersInTags() { var mockClient = new Mock(MockBehavior.Strict); @@ -481,7 +481,7 @@ public void TagsFiltersTests_SpecialCharactersInTags() } [Fact] - public void TagsFiltersTests_EscapedCommaCharactersInTags() + public void TagFiltersTests_EscapedCommaCharactersInTags() { var mockClient = new Mock(MockBehavior.Strict); @@ -521,7 +521,7 @@ public void TagsFiltersTests_EscapedCommaCharactersInTags() } [Fact] - public async Task TagsFiltersTests_BasicRefresh() + public async Task TagFiltersTests_BasicRefresh() { var mockClient = new Mock(MockBehavior.Strict); IConfigurationRefresher refresher = null; From 7ca2704fa1a805baf9a46bd9980976feddb40373 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Mon, 7 Apr 2025 11:53:45 -0700 Subject: [PATCH 44/48] remove tagsfilters again --- .../Models/KeyValueSelector.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 883750abf..222b9eeee 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -63,7 +63,7 @@ public override bool Equals(object obj) /// A hash code for the current object. public override int GetHashCode() { - int tagsFiltersHashCode = 3; + int tagFiltersHashCode = 3; if (TagFilters != null && TagFilters.Any()) { @@ -72,14 +72,14 @@ public override int GetHashCode() // Concatenate tags into a single string with a delimiter string tagsString = string.Join("|", sortedTags); - tagsFiltersHashCode = tagsString.GetHashCode(); + tagFiltersHashCode = tagsString.GetHashCode(); } return HashCode.Combine( KeyFilter?.GetHashCode() ?? 0, LabelFilter?.GetHashCode() ?? 1, SnapshotName?.GetHashCode() ?? 2, - tagsFiltersHashCode, + tagFiltersHashCode, IsFeatureFlagSelector.GetHashCode()); } } From 9ac1ea15361556d2d3a830fc2f564a0cf80cdc2b Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 15 Apr 2025 13:55:45 -0700 Subject: [PATCH 45/48] PR comments --- .../AzureAppConfigurationOptions.cs | 2 +- .../FeatureManagement/FeatureFlagOptions.cs | 2 +- .../Models/KeyValueSelector.cs | 20 +++++++++---------- .../TagValue.cs | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index a6829b18f..eee086c61 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -229,7 +229,7 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter if (tagFilters != null) { - foreach (var tag in tagFilters) + foreach (string tag in tagFilters) { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 61ba39790..a91a2aef2 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -106,7 +106,7 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = if (tagFilters != null) { - foreach (var tag in tagFilters) + foreach (string tag in tagFilters) { if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 222b9eeee..1d48f5d5b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -50,7 +50,9 @@ public override bool Equals(object obj) return KeyFilter == selector.KeyFilter && LabelFilter == selector.LabelFilter && SnapshotName == selector.SnapshotName - && (TagFilters != null ? new HashSet(TagFilters).SetEquals(selector.TagFilters) : selector.TagFilters == null) + && (TagFilters == null + ? selector.TagFilters == null + : selector.TagFilters != null && new HashSet(TagFilters).SetEquals(selector.TagFilters)) && IsFeatureFlagSelector == selector.IsFeatureFlagSelector; } @@ -63,24 +65,22 @@ public override bool Equals(object obj) /// A hash code for the current object. public override int GetHashCode() { - int tagFiltersHashCode = 3; + string tagFiltersString = string.Empty; if (TagFilters != null && TagFilters.Any()) { var sortedTags = new SortedSet(TagFilters); // Concatenate tags into a single string with a delimiter - string tagsString = string.Join("|", sortedTags); - - tagFiltersHashCode = tagsString.GetHashCode(); + tagFiltersString = string.Join("|", sortedTags); } return HashCode.Combine( - KeyFilter?.GetHashCode() ?? 0, - LabelFilter?.GetHashCode() ?? 1, - SnapshotName?.GetHashCode() ?? 2, - tagFiltersHashCode, - IsFeatureFlagSelector.GetHashCode()); + KeyFilter, + LabelFilter, + SnapshotName, + tagFiltersString, + IsFeatureFlagSelector); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs index 9e339e36e..7522e7e13 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs @@ -4,7 +4,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { /// - /// Defines well known tag filter values that are used within Azure App Configuration. + /// Defines well known tag values that are used within Azure App Configuration. /// public class TagValue { From 4397a8e9c666929cdef2c90adbb3ca54f56a1e32 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Thu, 17 Apr 2025 09:31:59 -0700 Subject: [PATCH 46/48] PR comments --- .../FeatureManagement/FeatureFlagOptions.cs | 6 +++--- .../Models/KeyValueSelector.cs | 2 +- tests/Tests.AzureAppConfiguration/TagFiltersTests.cs | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index a91a2aef2..4b6d56d6d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -75,11 +75,11 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). /// /// - /// In addition to key and label filters, key-values from Azure App Configuration can be filtered based on their tag names and values. - /// Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. + /// In addition to key and label filters, feature flags from Azure App Configuration can be filtered based on their tag names and values. + /// Each tag filter must follow the format "tagName=tagValue". Only those feature flags will be loaded whose tags match all the tags provided here. /// Built in tag filter values: . For example, $"tagName={}". /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). - /// Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. + /// Up to 5 tag filters can be provided. If no tag filters are provided, feature flags will not be filtered based on tags. /// public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IEnumerable tagFilters = null) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 1d48f5d5b..f01eb6559 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -72,7 +72,7 @@ public override int GetHashCode() var sortedTags = new SortedSet(TagFilters); // Concatenate tags into a single string with a delimiter - tagFiltersString = string.Join("|", sortedTags); + tagFiltersString = string.Join("\n", sortedTags); } return HashCode.Combine( diff --git a/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs b/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs index 97592ea5e..f0e4e2636 100644 --- a/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs +++ b/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs @@ -18,7 +18,6 @@ namespace Tests.AzureAppConfiguration public class TagFiltersTests { private List _kvCollection; - private List _ffCollection; private const int MaxTagFilters = 5; public TagFiltersTests() From d59232943fd8a2a61d03f4a030f7fc0615079825 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Thu, 24 Apr 2025 13:33:29 -0700 Subject: [PATCH 47/48] Revert "Merge pull request #600 from Azure/rossgrambo/allocation_id" This reverts commit 51d4ad729a09e6e1efcfe922c302c573b10f8700, reversing changes made to d5515369a5ef31435352719b361b8866043093a9. --- .../Extensions/StringExtensions.cs | 7 - .../FeatureManagementConstants.cs | 1 - .../FeatureManagementKeyValueAdapter.cs | 87 --------- .../JsonElementExtensions.cs | 91 ---------- .../FeatureManagementTests.cs | 167 ------------------ 5 files changed, 353 deletions(-) delete mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs index 1bc69ae90..7ee114826 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs @@ -39,12 +39,5 @@ public static string NormalizeNull(this string s) { return s == LabelFilter.Null ? null : s; } - - public static string ToBase64String(this string s) - { - byte[] bytes = System.Text.Encoding.UTF8.GetBytes(s); - - return Convert.ToBase64String(bytes); - } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs index af4647ee4..6d385797f 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs @@ -43,7 +43,6 @@ internal class FeatureManagementConstants public const string ETag = "ETag"; public const string FeatureFlagId = "FeatureFlagId"; public const string FeatureFlagReference = "FeatureFlagReference"; - public const string AllocationId = "AllocationId"; // Dotnet schema keys public const string DotnetSchemaSectionName = "FeatureManagement"; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index c6d284929..76ab04e4a 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Net.Mime; using System.Security.Cryptography; @@ -324,98 +323,12 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.ETag}", setting.ETag.ToString())); keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Enabled}", telemetry.Enabled.ToString())); - - if (featureFlag.Allocation != null) - { - string allocationId = CalculateAllocationId(featureFlag); - - if (allocationId != null) - { - keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.AllocationId}", allocationId)); - } - } } } return keyValues; } - private string CalculateAllocationId(FeatureFlag flag) - { - Debug.Assert(flag.Allocation != null); - - StringBuilder inputBuilder = new StringBuilder(); - - // Seed - inputBuilder.Append($"seed={flag.Allocation.Seed ?? string.Empty}"); - - var allocatedVariants = new HashSet(); - - // DefaultWhenEnabled - if (flag.Allocation.DefaultWhenEnabled != null) - { - allocatedVariants.Add(flag.Allocation.DefaultWhenEnabled); - } - - inputBuilder.Append($"\ndefault_when_enabled={flag.Allocation.DefaultWhenEnabled ?? string.Empty}"); - - // Percentiles - inputBuilder.Append("\npercentiles="); - - if (flag.Allocation.Percentile != null && flag.Allocation.Percentile.Any()) - { - IEnumerable sortedPercentiles = flag.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.ToBase64String()},{p.To}"))); - } - - // If there's no custom seed and no variants allocated, stop now and return null - if (flag.Allocation.Seed == null && - !allocatedVariants.Any()) - { - return null; - } - - // Variants - inputBuilder.Append("\nvariants="); - - if (allocatedVariants.Any() && flag.Variants != null && flag.Variants.Any()) - { - IEnumerable sortedVariants = flag.Variants - .Where(variant => allocatedVariants.Contains(variant.Name)) - .OrderBy(variant => variant.Name) - .ToList(); - - inputBuilder.Append(string.Join(";", sortedVariants.Select(v => - { - var variantValue = string.Empty; - - if (v.ConfigurationValue.ValueKind != JsonValueKind.Null && v.ConfigurationValue.ValueKind != JsonValueKind.Undefined) - { - variantValue = v.ConfigurationValue.SerializeWithSortedKeys(); - } - - return $"{v.Name.ToBase64String()},{(variantValue)}"; - }))); - } - - // Example input string - // input == "seed=123abc\ndefault_when_enabled=Control\npercentiles=0,Blshdk,20;20,Test,100\nvariants=TdLa,standard;Qfcd,special" - string input = inputBuilder.ToString(); - - using (SHA256 sha256 = SHA256.Create()) - { - byte[] truncatedHash = new byte[15]; - Array.Copy(sha256.ComputeHash(Encoding.UTF8.GetBytes(input)), truncatedHash, 15); - return truncatedHash.ToBase64Url(); - } - } - private FormatException CreateFeatureFlagFormatException(string jsonPropertyName, string settingKey, string foundJsonValueKind, string expectedJsonValueKind) { return new FormatException(string.Format( diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs deleted file mode 100644 index fc7f8b26d..000000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.Json; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement -{ - internal static class JsonElementExtensions - { - public static string SerializeWithSortedKeys(this JsonElement rootElement) - { - using var stream = new MemoryStream(); - - using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false })) - { - WriteElementWithSortedKeys(rootElement, writer); - } - - return Encoding.UTF8.GetString(stream.ToArray()); - } - - private static void WriteElementWithSortedKeys(JsonElement element, Utf8JsonWriter writer) - { - switch (element.ValueKind) - { - case JsonValueKind.Object: - writer.WriteStartObject(); - - foreach (JsonProperty property in element.EnumerateObject().OrderBy(p => p.Name)) - { - writer.WritePropertyName(property.Name); - WriteElementWithSortedKeys(property.Value, writer); - } - - writer.WriteEndObject(); - break; - - case JsonValueKind.Array: - writer.WriteStartArray(); - - foreach (JsonElement item in element.EnumerateArray()) - { - WriteElementWithSortedKeys(item, writer); - } - - writer.WriteEndArray(); - break; - - case JsonValueKind.String: - writer.WriteStringValue(element.GetString()); - break; - - case JsonValueKind.Number: - if (element.TryGetInt32(out int intValue)) - { - writer.WriteNumberValue(intValue); - } - else if (element.TryGetInt64(out long longValue)) - { - writer.WriteNumberValue(longValue); - } - else if (element.TryGetDecimal(out decimal decimalValue)) - { - writer.WriteNumberValue(element.GetDecimal()); - } - else if (element.TryGetDouble(out double doubleValue)) - { - writer.WriteNumberValue(element.GetDouble()); - } - - break; - - case JsonValueKind.True: - writer.WriteBooleanValue(true); - break; - - case JsonValueKind.False: - writer.WriteBooleanValue(false); - break; - - case JsonValueKind.Null: - writer.WriteNullValue(); - break; - - default: - throw new InvalidOperationException($"Unsupported JsonValueKind: {element.ValueKind}"); - } - } - } -} diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 41e95959b..6756b949f 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -621,114 +621,6 @@ public class FeatureManagementTests eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) }; - List _allocationIdFeatureFlagCollection = new List - { - ConfigurationModelFactory.ConfigurationSetting( - key: FeatureManagementConstants.FeatureFlagMarker + "TelemetryVariant", - value: @" - { - ""id"": ""TelemetryVariant"", - ""enabled"": true, - ""variants"": [ - { - ""name"": ""True_Override"", - ""configuration_value"": ""default"", - ""status_override"": ""Disabled"" - } - ], - ""allocation"": { - ""default_when_enabled"": ""True_Override"" - }, - ""telemetry"": { - ""enabled"": ""true"" - } - } - ", - contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", - eTag: new ETag("cmwBRcIAq1jUyKL3Kj8bvf9jtxBrFg-R-ayExStMC90")), - - ConfigurationModelFactory.ConfigurationSetting( - key: FeatureManagementConstants.FeatureFlagMarker + "TelemetryVariantPercentile", - value: @" - { - ""id"": ""TelemetryVariantPercentile"", - ""enabled"": true, - ""variants"": [ - { - ""name"": ""True_Override"", - ""configuration_value"": { - ""someOtherKey"": { - ""someSubKey"": ""someSubValue"" - }, - ""someKey4"": [3, 1, 4, true], - ""someKey"": ""someValue"", - ""someKey3"": 3.14, - ""someKey2"": 3 - } - } - ], - ""allocation"": { - ""default_when_enabled"": ""True_Override"", - ""percentile"": [ - { - ""variant"": ""True_Override"", - ""from"": 0, - ""to"": 100 - } - ] - }, - ""telemetry"": { - ""enabled"": ""true"" - } - } - ", - label: "label", - contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", - eTag: new ETag("cmwBRcIAq1jUyKL3Kj8bvf9jtxBrFg-R-ayExStMC90")), - - // Quote of the day test - ConfigurationModelFactory.ConfigurationSetting( - key: FeatureManagementConstants.FeatureFlagMarker + "Greeting", - value: @" - { - ""id"": ""Greeting"", - ""description"": """", - ""enabled"": true, - ""variants"": [ - { - ""name"": ""On"", - ""configuration_value"": true - }, - { - ""name"": ""Off"", - ""configuration_value"": false - } - ], - ""allocation"": { - ""percentile"": [ - { - ""variant"": ""On"", - ""from"": 0, - ""to"": 50 - }, - { - ""variant"": ""Off"", - ""from"": 50, - ""to"": 100 - } - ], - ""default_when_enabled"": ""Off"", - ""default_when_disabled"": ""Off"" - }, - ""telemetry"": { - ""enabled"": true - } - } - ", - contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", - eTag: new ETag("8kS3pc_cQmWnfLY9LQ1cd-RfR6_nQqH6sgdlL9eCgek")), - }; - TimeSpan RefreshInterval = TimeSpan.FromSeconds(1); [Fact] @@ -2171,65 +2063,6 @@ public void WithTelemetry() Assert.Equal("Tag2Value", config["feature_management:feature_flags:1:telemetry:metadata:Tags.Tag1"]); } - [Fact] - public void WithAllocationId() - { - var mockResponse = new Mock(); - var mockClient = new Mock(MockBehavior.Strict); - - mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(_allocationIdFeatureFlagCollection)); - - var config = new ConfigurationBuilder() - .AddAzureAppConfiguration(options => - { - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.Connect(TestHelpers.PrimaryConfigStoreEndpoint, new DefaultAzureCredential()); - options.UseFeatureFlags(); - }) - .Build(); - - byte[] featureFlagIdHash; - - using (HashAlgorithm hashAlgorithm = SHA256.Create()) - { - featureFlagIdHash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes($"{FeatureManagementConstants.FeatureFlagMarker}TelemetryVariant\n")); - } - - string featureFlagId = Convert.ToBase64String(featureFlagIdHash) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - - // Validate TelemetryVariant - Assert.Equal("True", config["feature_management:feature_flags:0:telemetry:enabled"]); - Assert.Equal("TelemetryVariant", config["feature_management:feature_flags:0:id"]); - - Assert.Equal(featureFlagId, config["feature_management:feature_flags:0:telemetry:metadata:FeatureFlagId"]); - - Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}TelemetryVariant", config["feature_management:feature_flags:0:telemetry:metadata:FeatureFlagReference"]); - - Assert.Equal("MExY1waco2tqen4EcJKK", config["feature_management:feature_flags:0:telemetry:metadata:AllocationId"]); - - // Validate TelemetryVariantPercentile - Assert.Equal("True", config["feature_management:feature_flags:1:telemetry:enabled"]); - Assert.Equal("TelemetryVariantPercentile", config["feature_management:feature_flags:1:id"]); - - Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}TelemetryVariantPercentile?label=label", config["feature_management:feature_flags:1:telemetry:metadata:FeatureFlagReference"]); - - Assert.Equal("YsdJ4pQpmhYa8KEhRLUn", config["feature_management:feature_flags:1:telemetry:metadata:AllocationId"]); - - // Validate Greeting - Assert.Equal("True", config["feature_management:feature_flags:2:telemetry:enabled"]); - Assert.Equal("Greeting", config["feature_management:feature_flags:2:id"]); - - Assert.Equal("63pHsrNKDSi5Zfe_FvZPSegwbsEo5TS96hf4k7cc4Zw", config["feature_management:feature_flags:2:telemetry:metadata:FeatureFlagId"]); - - Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}Greeting", config["feature_management:feature_flags:2:telemetry:metadata:FeatureFlagReference"]); - - Assert.Equal("L0m7_ulkdsaQmz6dSw4r", config["feature_management:feature_flags:2:telemetry:metadata:AllocationId"]); - } - [Fact] public void WithRequirementType() { From c465ddae50f0393be35468a76799ea360017ae98 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Thu, 24 Apr 2025 13:35:23 -0700 Subject: [PATCH 48/48] Revert "Give the users the ability to have control over ConfigurationClient instance(s) used by the provider (#598) (#617)" This reverts commit 6dc9ae2a42926952eb21339526264407bba5d5ee. --- .../AzureAppConfigurationClientFactory.cs | 74 ------------------- .../AzureAppConfigurationOptions.cs | 17 ----- .../AzureAppConfigurationSource.cs | 32 ++++---- .../ConfigurationClientManager.cs | 64 +++++++++++++--- .../FailoverTests.cs | 19 ++--- 5 files changed, 79 insertions(+), 127 deletions(-) delete mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs deleted file mode 100644 index 6127822d8..000000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Azure.Core; -using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Azure; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration -{ - internal class AzureAppConfigurationClientFactory : IAzureClientFactory - { - private readonly ConfigurationClientOptions _clientOptions; - - private readonly TokenCredential _credential; - private readonly IEnumerable _connectionStrings; - - public AzureAppConfigurationClientFactory( - IEnumerable connectionStrings, - ConfigurationClientOptions clientOptions) - { - if (connectionStrings == null || !connectionStrings.Any()) - { - throw new ArgumentNullException(nameof(connectionStrings)); - } - - _connectionStrings = connectionStrings; - - _clientOptions = clientOptions ?? throw new ArgumentNullException(nameof(clientOptions)); - } - - public AzureAppConfigurationClientFactory( - TokenCredential credential, - ConfigurationClientOptions clientOptions) - { - _credential = credential ?? throw new ArgumentNullException(nameof(credential)); - _clientOptions = clientOptions ?? throw new ArgumentNullException(nameof(clientOptions)); - } - - public ConfigurationClient CreateClient(string endpoint) - { - if (string.IsNullOrEmpty(endpoint)) - { - throw new ArgumentNullException(nameof(endpoint)); - } - - if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri uriResult)) - { - throw new ArgumentException("Invalid host URI."); - } - - if (_credential != null) - { - return new ConfigurationClient(uriResult, _credential, _clientOptions); - } - - string connectionString = _connectionStrings.FirstOrDefault(cs => ConnectionStringUtils.Parse(cs, ConnectionStringUtils.EndpointSection) == endpoint); - - // - // falback to the first connection string - if (connectionString == null) - { - string id = ConnectionStringUtils.Parse(_connectionStrings.First(), ConnectionStringUtils.IdSection); - string secret = ConnectionStringUtils.Parse(_connectionStrings.First(), ConnectionStringUtils.SecretSection); - - connectionString = ConnectionStringUtils.Build(uriResult, id, secret); - } - - return new ConfigurationClient(connectionString, _clientOptions); - } - } -} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index ba2299297..975f1ab32 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -3,7 +3,6 @@ // using Azure.Core; using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; @@ -148,11 +147,6 @@ internal IEnumerable Adapters /// internal StartupOptions Startup { get; set; } = new StartupOptions(); - /// - /// Client factory that is responsible for creating instances of ConfigurationClient. - /// - internal IAzureClientFactory ClientFactory { get; private set; } - /// /// Initializes a new instance of the class. /// @@ -169,17 +163,6 @@ public AzureAppConfigurationOptions() _selectors = new List { DefaultQuery }; } - /// - /// Sets the client factory used to create ConfigurationClient instances. - /// - /// The client factory. - /// The current instance. - public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory factory) - { - ClientFactory = factory ?? throw new ArgumentNullException(nameof(factory)); - return this; - } - /// /// Specify what key-values to include in the configuration provider. /// can be called multiple times to include multiple sets of key-values. diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs index 83d20e2fb..dee620060 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -1,11 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // -using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Azure; using System; -using System.Collections.Generic; -using System.Linq; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { @@ -33,33 +29,35 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) try { AzureAppConfigurationOptions options = _optionsProvider(); + IConfigurationClientManager clientManager; if (options.ClientManager != null) { - return new AzureAppConfigurationProvider(options.ClientManager, options, _optional); + clientManager = options.ClientManager; } - - IEnumerable endpoints; - IAzureClientFactory clientFactory = options.ClientFactory; - - if (options.ConnectionStrings != null) + else if (options.ConnectionStrings != null) { - endpoints = options.ConnectionStrings.Select(cs => new Uri(ConnectionStringUtils.Parse(cs, ConnectionStringUtils.EndpointSection))); - - clientFactory ??= new AzureAppConfigurationClientFactory(options.ConnectionStrings, options.ClientOptions); + clientManager = new ConfigurationClientManager( + options.ConnectionStrings, + options.ClientOptions, + options.ReplicaDiscoveryEnabled, + options.LoadBalancingEnabled); } else if (options.Endpoints != null && options.Credential != null) { - endpoints = options.Endpoints; - - clientFactory ??= new AzureAppConfigurationClientFactory(options.Credential, options.ClientOptions); + clientManager = new ConfigurationClientManager( + options.Endpoints, + options.Credential, + options.ClientOptions, + options.ReplicaDiscoveryEnabled, + options.LoadBalancingEnabled); } else { throw new ArgumentException($"Please call {nameof(AzureAppConfigurationOptions)}.{nameof(AzureAppConfigurationOptions.Connect)} to specify how to connect to Azure App Configuration."); } - provider = new AzureAppConfigurationProvider(new ConfigurationClientManager(clientFactory, endpoints, options.ReplicaDiscoveryEnabled, options.LoadBalancingEnabled), options, _optional); + provider = new AzureAppConfigurationProvider(clientManager, options, _optional); } catch (InvalidOperationException ex) // InvalidOperationException is thrown when any problems are found while configuring AzureAppConfigurationOptions or when SDK fails to create a configurationClient. { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs index 61840d036..a0215ca37 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs @@ -2,10 +2,10 @@ // Licensed under the MIT license. // +using Azure.Core; using Azure.Data.AppConfiguration; using DnsClient; using DnsClient.Protocol; -using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using System; using System.Collections.Generic; @@ -26,11 +26,12 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration /// internal class ConfigurationClientManager : IConfigurationClientManager, IDisposable { - private readonly IAzureClientFactory _clientFactory; private readonly IList _clients; - private readonly Uri _endpoint; - + private readonly string _secret; + private readonly string _id; + private readonly TokenCredential _credential; + private readonly ConfigurationClientOptions _clientOptions; private readonly bool _replicaDiscoveryEnabled; private readonly SrvLookupClient _srvLookupClient; private readonly string _validDomain; @@ -51,20 +52,61 @@ internal class ConfigurationClientManager : IConfigurationClientManager, IDispos internal int RefreshClientsCalled { get; set; } = 0; public ConfigurationClientManager( - IAzureClientFactory clientFactory, - IEnumerable endpoints, + IEnumerable connectionStrings, + ConfigurationClientOptions clientOptions, bool replicaDiscoveryEnabled, bool loadBalancingEnabled) { - _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + if (connectionStrings == null || !connectionStrings.Any()) + { + throw new ArgumentNullException(nameof(connectionStrings)); + } + + string connectionString = connectionStrings.First(); + _endpoint = new Uri(ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.EndpointSection)); + _secret = ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.SecretSection); + _id = ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.IdSection); + _clientOptions = clientOptions; + _replicaDiscoveryEnabled = replicaDiscoveryEnabled; + + // If load balancing is enabled, shuffle the passed in connection strings to randomize the endpoint used on startup + if (loadBalancingEnabled) + { + connectionStrings = connectionStrings.ToList().Shuffle(); + } + + _validDomain = GetValidDomain(_endpoint); + _srvLookupClient = new SrvLookupClient(); + _clients = connectionStrings + .Select(cs => + { + var endpoint = new Uri(ConnectionStringUtils.Parse(cs, ConnectionStringUtils.EndpointSection)); + return new ConfigurationClientWrapper(endpoint, new ConfigurationClient(cs, _clientOptions)); + }) + .ToList(); + } + + public ConfigurationClientManager( + IEnumerable endpoints, + TokenCredential credential, + ConfigurationClientOptions clientOptions, + bool replicaDiscoveryEnabled, + bool loadBalancingEnabled) + { if (endpoints == null || !endpoints.Any()) { throw new ArgumentNullException(nameof(endpoints)); } - _endpoint = endpoints.First(); + if (credential == null) + { + throw new ArgumentNullException(nameof(credential)); + } + _endpoint = endpoints.First(); + _credential = credential; + _clientOptions = clientOptions; _replicaDiscoveryEnabled = replicaDiscoveryEnabled; // If load balancing is enabled, shuffle the passed in endpoints to randomize the endpoint used on startup @@ -77,7 +119,7 @@ public ConfigurationClientManager( _srvLookupClient = new SrvLookupClient(); _clients = endpoints - .Select(endpoint => new ConfigurationClientWrapper(endpoint, clientFactory.CreateClient(endpoint.AbsoluteUri))) + .Select(endpoint => new ConfigurationClientWrapper(endpoint, new ConfigurationClient(endpoint, _credential, _clientOptions))) .ToList(); } @@ -247,7 +289,9 @@ private async Task RefreshFallbackClients(CancellationToken cancellationToken) { var targetEndpoint = new Uri($"https://{host}"); - ConfigurationClient configClient = _clientFactory.CreateClient(targetEndpoint.AbsoluteUri); + var configClient = _credential == null + ? new ConfigurationClient(ConnectionStringUtils.Build(targetEndpoint, _id, _secret), _clientOptions) + : new ConfigurationClient(targetEndpoint, _credential, _clientOptions); newDynamicClients.Add(new ConfigurationClientWrapper(targetEndpoint, configClient)); } diff --git a/tests/Tests.AzureAppConfiguration/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/FailoverTests.cs index 929f9befc..86ea96b97 100644 --- a/tests/Tests.AzureAppConfiguration/FailoverTests.cs +++ b/tests/Tests.AzureAppConfiguration/FailoverTests.cs @@ -268,11 +268,10 @@ public void FailOverTests_AutoFailover() [Fact] public void FailOverTests_ValidateEndpoints() { - var clientFactory = new AzureAppConfigurationClientFactory(new DefaultAzureCredential(), new ConfigurationClientOptions()); - var configClientManager = new ConfigurationClientManager( - clientFactory, new[] { new Uri("https://foobar.azconfig.io") }, + new DefaultAzureCredential(), + new ConfigurationClientOptions(), true, false); @@ -286,8 +285,9 @@ public void FailOverTests_ValidateEndpoints() Assert.False(configClientManager.IsValidEndpoint("azure.azconfig.bad.io")); var configClientManager2 = new ConfigurationClientManager( - clientFactory, new[] { new Uri("https://foobar.appconfig.azure.com") }, + new DefaultAzureCredential(), + new ConfigurationClientOptions(), true, false); @@ -301,8 +301,9 @@ public void FailOverTests_ValidateEndpoints() Assert.False(configClientManager2.IsValidEndpoint("azure.appconfigbad.azure.com")); var configClientManager3 = new ConfigurationClientManager( - clientFactory, new[] { new Uri("https://foobar.azconfig-test.io") }, + new DefaultAzureCredential(), + new ConfigurationClientOptions(), true, false); @@ -310,8 +311,9 @@ public void FailOverTests_ValidateEndpoints() Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig.io")); var configClientManager4 = new ConfigurationClientManager( - clientFactory, new[] { new Uri("https://foobar.z1.appconfig-test.azure.com") }, + new DefaultAzureCredential(), + new ConfigurationClientOptions(), true, false); @@ -323,11 +325,10 @@ public void FailOverTests_ValidateEndpoints() [Fact] public void FailOverTests_GetNoDynamicClient() { - var clientFactory = new AzureAppConfigurationClientFactory(new DefaultAzureCredential(), new ConfigurationClientOptions()); - var configClientManager = new ConfigurationClientManager( - clientFactory, new[] { new Uri("https://azure.azconfig.io") }, + new DefaultAzureCredential(), + new ConfigurationClientOptions(), true, false);