From 2de6c93e5fb929f68d6d86b510e48c0175f36f8c Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:09:25 -0700 Subject: [PATCH 01/37] Use TryAddSingleton instead of AddSingleton for extension (#643) * use TryAddSingleton instead of AddSingleton in extension, fix refresherprovider accordingly * remove unused using * add comment --- .../AzureAppConfigurationExtensions.cs | 5 +-- .../AzureAppConfigurationRefresherProvider.cs | 35 ++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs index 8b4bf8c8..f8c2c5ca 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs @@ -3,6 +3,7 @@ // using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using System; using System.Collections.Generic; using System.Security; @@ -99,11 +100,11 @@ public static IServiceCollection AddAzureAppConfiguration(this IServiceCollectio if (!_isProviderDisabled) { services.AddLogging(); - services.AddSingleton(); + services.TryAddSingleton(); } else { - services.AddSingleton(); + services.TryAddSingleton(); } return services; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs index e86f6cc1..d2b31071 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs @@ -13,11 +13,38 @@ internal class AzureAppConfigurationRefresherProvider : IConfigurationRefresherP { private static readonly PropertyInfo _propertyInfo = typeof(ChainedConfigurationProvider).GetProperty("Configuration", BindingFlags.Public | BindingFlags.Instance); - public IEnumerable Refreshers { get; } + private readonly IConfiguration _configuration; + private readonly ILoggerFactory _loggerFactory; + private IEnumerable _refreshers; + private bool _rediscoveredRefreshers = false; - public AzureAppConfigurationRefresherProvider(IConfiguration configuration, ILoggerFactory _loggerFactory) + public IEnumerable Refreshers { - var configurationRoot = configuration as IConfigurationRoot; + get + { + // Ensure latest refreshers are discovered if the configuration has changed since the constructor was called + if (!_rediscoveredRefreshers) + { + _refreshers = DiscoverRefreshers(); + + _rediscoveredRefreshers = true; + } + + return _refreshers; + } + } + + public AzureAppConfigurationRefresherProvider(IConfiguration configuration, ILoggerFactory loggerFactory) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _loggerFactory = loggerFactory; + + _refreshers = DiscoverRefreshers(); + } + + private IEnumerable DiscoverRefreshers() + { + var configurationRoot = _configuration as IConfigurationRoot; var refreshers = new List(); FindRefreshers(configurationRoot, _loggerFactory, refreshers); @@ -27,7 +54,7 @@ public AzureAppConfigurationRefresherProvider(IConfiguration configuration, ILog throw new InvalidOperationException("Unable to access the Azure App Configuration provider. Please ensure that it has been configured correctly."); } - Refreshers = refreshers; + return refreshers; } private void FindRefreshers(IConfigurationRoot configurationRoot, ILoggerFactory loggerFactory, List refreshers) From 2b004fb827dbb9fbd3c82e17022305ab749eb04c Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 17 Apr 2025 10:31:45 +0800 Subject: [PATCH 02/37] Fix format (#647) * fix format * fix format * fix format * fix format --- .../AzureAppConfigurationOptions.cs | 10 +++++----- .../ConfigurationSettingPageExtensions.cs | 4 ++-- .../IConfigurationSettingPageIterator.cs | 4 ++-- .../FeatureManagementTests.cs | 7 +++++-- .../KeyVaultReferenceTests.cs | 4 ++-- tests/Tests.AzureAppConfiguration/RefreshTests.cs | 6 +++--- 6 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index db3b6c3d..67e8e993 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -2,7 +2,7 @@ // Licensed under the MIT license. // using Azure.Core; -using Azure.Core.Pipeline; +using Azure.Core.Pipeline; using Azure.Data.AppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; @@ -11,7 +11,7 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; +using System.Net.Http; using System.Threading.Tasks; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration @@ -25,7 +25,7 @@ public class AzureAppConfigurationOptions private const int MaxRetries = 2; 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 }; + private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; private List _individualKvWatchers = new List(); private List _ffWatchers = new List(); @@ -514,9 +514,9 @@ private static ConfigurationClientOptions GetDefaultClientOptions() clientOptions.Retry.Mode = RetryMode.Exponential; clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); clientOptions.Transport = new HttpClientTransport(new HttpClient() - { + { Timeout = NetworkTimeout - }); + }); return clientOptions; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs index aba7684b..ad69330d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationSettingPageExtensions.cs @@ -1,5 +1,5 @@ -using Azure.Data.AppConfiguration; -using Azure; +using Azure; +using Azure.Data.AppConfiguration; using System.Collections.Generic; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs index 08c95751..7bbc3ded 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationSettingPageIterator.cs @@ -1,5 +1,5 @@ -using Azure.Data.AppConfiguration; -using Azure; +using Azure; +using Azure.Data.AppConfiguration; using System.Collections.Generic; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index d96e9e39..2f935381 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -731,6 +731,7 @@ public async Task WatchesFeatureFlags() } [Fact] + [Obsolete] public async Task WatchesFeatureFlagsUsingCacheExpirationInterval() { var featureFlags = new List { _kv }; @@ -874,6 +875,7 @@ public async Task SkipRefreshIfRefreshIntervalHasNotElapsed() } [Fact] + [Obsolete] public async Task SkipRefreshIfCacheNotExpired() { var featureFlags = new List { _kv }; @@ -1174,7 +1176,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) if (newSetting != null) copy.Add(TestHelpers.CloneSetting(newSetting)); return new MockAsyncPageable(copy); - }; + } var testClient = mockClient.Object; @@ -1199,6 +1201,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) } [Fact] + [Obsolete] public void AlternateValidFeatureFlagFormats() { var mockResponse = new Mock(); @@ -1215,7 +1218,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) if (newSetting != null) copy.Add(TestHelpers.CloneSetting(newSetting)); return new MockAsyncPageable(copy); - }; + } var testClient = mockClient.Object; diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs index 274bfe7f..3e856a1b 100644 --- a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs @@ -982,7 +982,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) if (newSetting != null) copy.Add(TestHelpers.CloneSetting(newSetting)); return new MockAsyncPageable(copy); - }; + } var testClient = mockClient.Object; @@ -1028,7 +1028,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) if (newSetting != null) copy.Add(TestHelpers.CloneSetting(newSetting)); return new MockAsyncPageable(copy); - }; + } var testClient = mockClient.Object; diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index e64b5184..c27a48ff 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -320,7 +320,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o foreach (var setting in keyValueCollection) { copy.Add(TestHelpers.CloneSetting(setting)); - }; + } return new MockAsyncPageable(copy); }); @@ -392,7 +392,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o foreach (var setting in keyValueCollection) { copy.Add(TestHelpers.CloneSetting(setting)); - }; + } return new MockAsyncPageable(copy); }); @@ -461,7 +461,7 @@ public async Task RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() foreach (var setting in keyValueCollection) { copy.Add(TestHelpers.CloneSetting(setting)); - }; + } return new MockAsyncPageable(copy, operationDelay); }); From 3d51200ee2b2f971166ade7a44fe5f72a5dd6c96 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Thu, 17 Apr 2025 09:50:20 -0700 Subject: [PATCH 03/37] Add request tracing for content type (#646) * add initial content type pattern * format fix * fix comment * update to account for chat completion vs ai profiles * in progress fix adapter to use existing requesttracingoptions * use content type tracing to pass to requesttracingoptions * fix comments and naming * remove unneeded file * add check for request tracing enabled * check content type in preparedata * remove errors * fix spacing * fix test * remove unused usings, add back catch for .net framework * fix parsing * rename constants * fix indent * update for PR comments * PR comments, update if conditions * add isjson check * update isjson extension --- .../AzureAppConfigurationOptions.cs | 2 +- .../AzureAppConfigurationProvider.cs | 14 +++- .../Constants/RequestTracingConstants.cs | 6 ++ .../Extensions/ContentTypeExtensions.cs | 81 +++++++++++++++++++ .../Extensions/StringExtensions.cs | 30 +++++++ .../JsonKeyValueAdapter.cs | 42 +--------- .../RequestTracingOptions.cs | 68 +++++++++++++++- 7 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 67e8e993..6be96b65 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -141,7 +141,7 @@ internal IEnumerable Adapters internal bool IsKeyVaultRefreshConfigured { get; private set; } = false; /// - /// Indicates all types of feature filters used by the application. + /// Indicates all feature flag features used by the application. /// internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 795aeb10..d86133ae 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -597,9 +597,21 @@ private async Task> PrepareData(Dictionary kvp in data) { IEnumerable> keyValuePairs = null; + + if (_requestTracingEnabled && _requestTracingOptions != null) + { + _requestTracingOptions.UpdateAiConfigurationTracing(kvp.Value.ContentType); + } + keyValuePairs = await ProcessAdapters(kvp.Value, cancellationToken).ConfigureAwait(false); foreach (KeyValuePair kv in keyValuePairs) @@ -636,7 +648,7 @@ private async Task LoadAsync(bool ignoreFailures, CancellationToken cancellation { IEnumerable clients = _configClientManager.GetClients(); - if (_requestTracingOptions != null) + if (_requestTracingEnabled && _requestTracingOptions != null) { _requestTracingOptions.ReplicaCount = clients.Count() - 1; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs index f732ab95..612e1bcc 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs @@ -31,6 +31,9 @@ internal class RequestTracingConstants public const string ReplicaCountKey = "ReplicaCount"; public const string FeaturesKey = "Features"; public const string LoadBalancingEnabledTag = "LB"; + public const string AIConfigurationTag = "AI"; + public const string AIChatCompletionConfigurationTag = "AICC"; + public const string SignalRUsedTag = "SignalR"; public const string FailoverRequestTag = "Failover"; public const string PushRefreshTag = "PushRefresh"; @@ -54,5 +57,8 @@ internal class RequestTracingConstants public const string SignalRAssemblyName = "Microsoft.AspNetCore.SignalR"; public const string Delimiter = "+"; + + public const string AIMimeProfile = "https://azconfig.io/mime-profiles/ai"; + public const string AIChatCompletionMimeProfile = "https://azconfig.io/mime-profiles/ai/chat-completion"; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs new file mode 100644 index 00000000..2f739a52 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Linq; +using System; +using System.Net.Mime; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions +{ + internal static class ContentTypeExtensions + { + private static readonly IEnumerable ExcludedJsonContentTypes = new[] + { + FeatureManagementConstants.ContentType, + KeyVaultConstants.ContentType + }; + + public static bool IsAi(this ContentType contentType) + { + return contentType != null && + contentType.IsJson() && + contentType.Parameters.ContainsKey("profile") && + !string.IsNullOrEmpty(contentType.Parameters["profile"]) && + contentType.Parameters["profile"].StartsWith(RequestTracingConstants.AIMimeProfile); + } + + public static bool IsAiChatCompletion(this ContentType contentType) + { + return contentType != null && + contentType.IsJson() && + contentType.Parameters.ContainsKey("profile") && + !string.IsNullOrEmpty(contentType.Parameters["profile"]) && + contentType.Parameters["profile"].StartsWith(RequestTracingConstants.AIChatCompletionMimeProfile); + } + + public static bool IsJson(this ContentType contentType) + { + if (contentType == null) + { + return false; + } + + string acceptedMainType = "application"; + string acceptedSubType = "json"; + string mediaType = contentType.MediaType; + + if (!ExcludedJsonContentTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) + { + ReadOnlySpan mediaTypeSpan = mediaType.AsSpan(); + + // Since contentType has been validated using System.Net.Mime.ContentType, + // mediaType will always have exactly 2 parts after splitting on '/' + int slashIndex = mediaTypeSpan.IndexOf('/'); + + if (mediaTypeSpan.Slice(0, slashIndex).Equals(acceptedMainType.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + ReadOnlySpan subTypeSpan = mediaTypeSpan.Slice(slashIndex + 1); + + while (!subTypeSpan.IsEmpty) + { + int plusIndex = subTypeSpan.IndexOf('+'); + + ReadOnlySpan currentSubType = plusIndex == -1 ? subTypeSpan : subTypeSpan.Slice(0, plusIndex); + + if (currentSubType.Equals(acceptedSubType.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + subTypeSpan = plusIndex == -1 ? ReadOnlySpan.Empty : subTypeSpan.Slice(plusIndex + 1); + } + } + } + + return false; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs index e97dfa2f..7ee11482 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/StringExtensions.cs @@ -1,10 +1,40 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System; +using System.Net.Mime; + namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class StringExtensions { + public static bool TryParseContentType(this string contentTypeString, out ContentType contentType) + { + contentType = null; + + if (string.IsNullOrWhiteSpace(contentTypeString)) + { + return false; + } + + try + { + contentType = new ContentType(contentTypeString.Trim()); + + return true; + } + catch (FormatException) + { + return false; + } + catch (IndexOutOfRangeException) + { + // Bug in System.Net.Mime.ContentType throws this if contentType is "xyz/" + // https://github.com/dotnet/runtime/issues/39337 + return false; + } + } + public static string NormalizeNull(this string s) { return s == LabelFilter.Null ? null : s; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs index b4448e32..87c28d69 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs @@ -2,11 +2,10 @@ // Licensed under the MIT license. // using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; using System; using System.Collections.Generic; -using System.Linq; using System.Net.Mime; using System.Text.Json; using System.Threading; @@ -16,12 +15,6 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { internal class JsonKeyValueAdapter : IKeyValueAdapter { - private static readonly IEnumerable ExcludedJsonContentTypes = new[] - { - FeatureManagementConstants.ContentType, - KeyVaultConstants.ContentType - }; - public Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken) { if (setting == null) @@ -58,38 +51,9 @@ public bool CanProcess(ConfigurationSetting setting) return false; } - string acceptedMainType = "application"; - string acceptedSubType = "json"; - string mediaType; - - try + if (setting.ContentType.TryParseContentType(out ContentType contentType)) { - mediaType = new ContentType(setting.ContentType.Trim()).MediaType; - } - catch (FormatException) - { - return false; - } - catch (IndexOutOfRangeException) - { - // Bug in System.Net.Mime.ContentType throws this if contentType is "xyz/" - // https://github.com/dotnet/runtime/issues/39337 - return false; - } - - if (!ExcludedJsonContentTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) - { - // Since contentType has been validated using System.Net.Mime.ContentType, - // mediaType will always have exactly 2 parts after splitting on '/' - string[] types = mediaType.Split('/'); - if (string.Equals(types[0], acceptedMainType, StringComparison.OrdinalIgnoreCase)) - { - string[] subTypes = types[1].Split('+'); - if (subTypes.Contains(acceptedSubType, StringComparer.OrdinalIgnoreCase)) - { - return true; - } - } + return contentType.IsJson(); } return false; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs index bd8b7582..21582db1 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using System.Net.Mime; using System.Text; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration @@ -68,13 +70,57 @@ internal class RequestTracingOptions /// public bool IsPushRefreshUsed { get; set; } = false; + /// + /// Flag to indicate whether any key-value uses the json content type and contains + /// a parameter indicating an AI profile. + /// + public bool UsesAIConfiguration { get; set; } = false; + + /// + /// Flag to indicate whether any key-value uses the json content type and contains + /// a parameter indicating an AI chat completion profile. + /// + public bool UsesAIChatCompletionConfiguration { get; set; } = false; + + /// + /// Resets the AI configuration tracing flags. + /// + public void ResetAiConfigurationTracing() + { + UsesAIConfiguration = false; + UsesAIChatCompletionConfiguration = false; + } + + /// + /// Updates AI configuration tracing flags based on the provided content type. + /// + /// The content type to analyze. + public void UpdateAiConfigurationTracing(string contentTypeString) + { + if (!UsesAIChatCompletionConfiguration && + !string.IsNullOrWhiteSpace(contentTypeString) && + contentTypeString.TryParseContentType(out ContentType contentType) && + contentType.IsAi()) + { + UsesAIConfiguration = true; + + if (contentType.IsAiChatCompletion()) + { + UsesAIChatCompletionConfiguration = true; + } + } + } + /// /// Checks whether any tracing feature is used. /// /// true if any tracing feature is used, otherwise false. public bool UsesAnyTracingFeature() { - return IsLoadBalancingEnabled || IsSignalRUsed; + return IsLoadBalancingEnabled || + IsSignalRUsed || + UsesAIConfiguration || + UsesAIChatCompletionConfiguration; } /// @@ -105,6 +151,26 @@ public string CreateFeaturesString() sb.Append(RequestTracingConstants.SignalRUsedTag); } + if (UsesAIConfiguration) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.AIConfigurationTag); + } + + if (UsesAIChatCompletionConfiguration) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.AIChatCompletionConfigurationTag); + } + return sb.ToString(); } } From fb7fb03e08f937c5c4fb062d314bd10b5b119111 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Thu, 17 Apr 2025 12:14:44 -0700 Subject: [PATCH 04/37] Fix test to use new refresh interval api (#650) * fix test to remove obsolete * change variable names --- tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 2f935381..6756b949 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -1201,12 +1201,11 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) } [Fact] - [Obsolete] public void AlternateValidFeatureFlagFormats() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); - var cacheExpiration = TimeSpan.FromSeconds(1); + var refreshInterval = TimeSpan.FromSeconds(1); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns((Func)GetTestKeys); @@ -1233,7 +1232,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); options.UseFeatureFlags(ff => { - ff.CacheExpirationInterval = cacheExpiration; + ff.SetRefreshInterval(refreshInterval); ff.Select(flagKey); }); }) From 4fd6ee7bc6971539d37965f0122609d2e3eb22f7 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:33:21 -0700 Subject: [PATCH 05/37] Separate content type exclusion logic from IsJson extension (#651) * fix isjson and separate exclusion logic * update adapters to use new extension * PR comments --- .../AzureKeyVaultKeyValueAdapter.cs | 14 ++++- .../Extensions/ContentTypeExtensions.cs | 56 ++++++++++--------- .../FeatureManagementKeyValueAdapter.cs | 17 +++++- .../JsonKeyValueAdapter.cs | 4 +- 4 files changed, 59 insertions(+), 32 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs index a272b413..0498ba09 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs @@ -4,9 +4,12 @@ using Azure; using Azure.Data.AppConfiguration; using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; using System; using System.Collections.Generic; using System.Linq; +using System.Net.Mime; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -72,8 +75,15 @@ KeyVaultReferenceException CreateKeyVaultReferenceException(string message, Conf public bool CanProcess(ConfigurationSetting setting) { - string contentType = setting?.ContentType?.Split(';')[0].Trim(); - return string.Equals(contentType, KeyVaultConstants.ContentType); + if (setting == null || + string.IsNullOrWhiteSpace(setting.Value) || + string.IsNullOrWhiteSpace(setting.ContentType)) + { + return false; + } + + return setting.ContentType.TryParseContentType(out ContentType contentType) + && contentType.IsKeyVaultReference(); } public void OnChangeDetected(ConfigurationSetting setting = null) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs index 2f739a52..ca2a9d53 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ContentTypeExtensions.cs @@ -12,16 +12,12 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class ContentTypeExtensions { - private static readonly IEnumerable ExcludedJsonContentTypes = new[] - { - FeatureManagementConstants.ContentType, - KeyVaultConstants.ContentType - }; - public static bool IsAi(this ContentType contentType) { return contentType != null && contentType.IsJson() && + !contentType.IsFeatureFlag() && + !contentType.IsKeyVaultReference() && contentType.Parameters.ContainsKey("profile") && !string.IsNullOrEmpty(contentType.Parameters["profile"]) && contentType.Parameters["profile"].StartsWith(RequestTracingConstants.AIMimeProfile); @@ -31,6 +27,8 @@ public static bool IsAiChatCompletion(this ContentType contentType) { return contentType != null && contentType.IsJson() && + !contentType.IsFeatureFlag() && + !contentType.IsKeyVaultReference() && contentType.Parameters.ContainsKey("profile") && !string.IsNullOrEmpty(contentType.Parameters["profile"]) && contentType.Parameters["profile"].StartsWith(RequestTracingConstants.AIChatCompletionMimeProfile); @@ -45,37 +43,43 @@ public static bool IsJson(this ContentType contentType) string acceptedMainType = "application"; string acceptedSubType = "json"; - string mediaType = contentType.MediaType; - - if (!ExcludedJsonContentTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) - { - ReadOnlySpan mediaTypeSpan = mediaType.AsSpan(); - // Since contentType has been validated using System.Net.Mime.ContentType, - // mediaType will always have exactly 2 parts after splitting on '/' - int slashIndex = mediaTypeSpan.IndexOf('/'); + ReadOnlySpan mediaTypeSpan = contentType.MediaType.AsSpan(); - if (mediaTypeSpan.Slice(0, slashIndex).Equals(acceptedMainType.AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - ReadOnlySpan subTypeSpan = mediaTypeSpan.Slice(slashIndex + 1); + // Since contentType has been validated using System.Net.Mime.ContentType, + // mediaType will always have exactly 2 parts after splitting on '/' + int slashIndex = mediaTypeSpan.IndexOf('/'); - while (!subTypeSpan.IsEmpty) - { - int plusIndex = subTypeSpan.IndexOf('+'); + if (mediaTypeSpan.Slice(0, slashIndex).Equals(acceptedMainType.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + ReadOnlySpan subTypeSpan = mediaTypeSpan.Slice(slashIndex + 1); - ReadOnlySpan currentSubType = plusIndex == -1 ? subTypeSpan : subTypeSpan.Slice(0, plusIndex); + while (!subTypeSpan.IsEmpty) + { + int plusIndex = subTypeSpan.IndexOf('+'); - if (currentSubType.Equals(acceptedSubType.AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - return true; - } + ReadOnlySpan currentSubType = plusIndex == -1 ? subTypeSpan : subTypeSpan.Slice(0, plusIndex); - subTypeSpan = plusIndex == -1 ? ReadOnlySpan.Empty : subTypeSpan.Slice(plusIndex + 1); + if (currentSubType.Equals(acceptedSubType.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return true; } + + subTypeSpan = plusIndex == -1 ? ReadOnlySpan.Empty : subTypeSpan.Slice(plusIndex + 1); } } return false; } + + public static bool IsFeatureFlag(this ContentType contentType) + { + return contentType.MediaType.Equals(FeatureManagementConstants.ContentType); + } + + public static bool IsKeyVaultReference(this ContentType contentType) + { + return contentType.MediaType.Equals(KeyVaultConstants.ContentType); + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index b6d137f3..76ab04e4 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Mime; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -45,10 +46,20 @@ public Task>> ProcessKeyValue(Configura public bool CanProcess(ConfigurationSetting setting) { - string contentType = setting?.ContentType?.Split(';')[0].Trim(); + if (setting == null || + string.IsNullOrWhiteSpace(setting.Value) || + string.IsNullOrWhiteSpace(setting.ContentType)) + { + return false; + } + + if (setting.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker)) + { + return true; + } - return string.Equals(contentType, FeatureManagementConstants.ContentType) || - setting.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker); + return setting.ContentType.TryParseContentType(out ContentType contentType) && + contentType.IsFeatureFlag(); } public bool NeedsRefresh() diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs index 87c28d69..74d2b882 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs @@ -53,7 +53,9 @@ public bool CanProcess(ConfigurationSetting setting) if (setting.ContentType.TryParseContentType(out ContentType contentType)) { - return contentType.IsJson(); + return contentType.IsJson() && + !contentType.IsFeatureFlag() && + !contentType.IsKeyVaultReference(); } return false; From 5910dbf11d41445b75060b30d78adb1342b092f2 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Mon, 21 Apr 2025 09:26:11 -0700 Subject: [PATCH 06/37] update package versions to 8.1.2 (#648) --- .../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 4cd6bf4e..b9f6bfc3 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.2 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 e327421b..6236ba4f 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.2 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 91b90bb1..d5af2b14 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.1.1 + 8.1.2 From 65ed48033482e796143b41fa150d06293fbf8316 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Mon, 21 Apr 2025 13:12:54 -0700 Subject: [PATCH 07/37] Revert "Shorten the defeult timeout of individual call to backend (#620)" (#653) This reverts commit 87f0f85ca2e4011f82d93a864e35c6c804cd6c39. --- .../AzureAppConfigurationOptions.cs | 7 ------- .../AzureAppConfigurationProvider.cs | 7 ------- 2 files changed, 14 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 6be96b65..6e600fa2 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. // using Azure.Core; -using Azure.Core.Pipeline; using Azure.Data.AppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; @@ -11,7 +10,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; using System.Threading.Tasks; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration @@ -24,7 +22,6 @@ public class AzureAppConfigurationOptions { private const int MaxRetries = 2; 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 }; private List _individualKvWatchers = new List(); @@ -513,10 +510,6 @@ private static ConfigurationClientOptions GetDefaultClientOptions() clientOptions.Retry.MaxDelay = MaxRetryDelay; clientOptions.Retry.Mode = RetryMode.Exponential; clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); - clientOptions.Transport = new HttpClientTransport(new HttpClient() - { - Timeout = NetworkTimeout - }); return clientOptions; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index d86133ae..5e1bf8e0 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -1221,13 +1221,6 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => private bool IsFailOverable(AggregateException ex) { - TaskCanceledException tce = ex.InnerExceptions?.LastOrDefault(e => e is TaskCanceledException) as TaskCanceledException; - - if (tce != null && tce.InnerException is TimeoutException) - { - return true; - } - RequestFailedException rfe = ex.InnerExceptions?.LastOrDefault(e => e is RequestFailedException) as RequestFailedException; return rfe != null ? IsFailOverable(rfe) : false; From bf8b06b9dde1bb219b625939b3d2ea00bd2a61d5 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 24 Apr 2025 11:00:32 +0800 Subject: [PATCH 08/37] Add activity source to allow OpenTelemetry to collect tracing (#645) * add activity source * add minimum requirement for MinBackOffDuration * Revert "add minimum requirement for MinBackOffDuration" This reverts commit 59d847a948b990a52d830c71cf89e908102bbc4b. * revert make MinBackoffDuration public * update * update activity name * update --- .../AzureAppConfigurationProvider.cs | 5 ++++- .../Constants/ActivityNames.cs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ActivityNames.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 5e1bf8e0..d1e18edb 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -23,6 +23,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher, IDisposable { + private readonly ActivitySource _activitySource = new ActivitySource(ActivityNames.AzureAppConfigurationActivitySource); private bool _optional; private bool _isInitialLoadComplete = false; private bool _isAssemblyInspected; @@ -158,7 +159,7 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan public override void Load() { var watch = Stopwatch.StartNew(); - + using Activity activity = _activitySource.StartActivity(ActivityNames.Load); try { using var startupCancellationTokenSource = new CancellationTokenSource(_options.Startup.Timeout); @@ -258,6 +259,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) return; } + using Activity activity = _activitySource.StartActivity(ActivityNames.Refresh); // Check if initial configuration load had failed if (_mappedData == null) { @@ -1406,6 +1408,7 @@ private async Task ProcessKeyValueChangesAsync( public void Dispose() { (_configClientManager as ConfigurationClientManager)?.Dispose(); + _activitySource.Dispose(); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ActivityNames.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ActivityNames.cs new file mode 100644 index 00000000..7b161425 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ActivityNames.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal static class ActivityNames + { + public const string AzureAppConfigurationActivitySource = "Microsoft.Extensions.Configuration.AzureAppConfiguration"; + public const string Load = "Load"; + public const string Refresh = "Refresh"; + } +} From d0d14a57d1e40fcba210a81720ef09a00af44496 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Fri, 25 Apr 2025 15:43:17 -0700 Subject: [PATCH 09/37] Removed FeatureFlagId --- .../FeatureManagementConstants.cs | 1 - .../FeatureManagementKeyValueAdapter.cs | 18 ----------- .../FeatureManagementTests.cs | 31 ------------------- 3 files changed, 50 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs index af4647ee..d344896a 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs @@ -41,7 +41,6 @@ internal class FeatureManagementConstants // Telemetry metadata keys public const string ETag = "ETag"; - public const string FeatureFlagId = "FeatureFlagId"; public const string FeatureFlagReference = "FeatureFlagReference"; public const string AllocationId = "AllocationId"; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index c6d28492..cbfa411a 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -310,10 +310,6 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea } } - string featureFlagId = CalculateFeatureFlagId(setting.Key, setting.Label); - - keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.FeatureFlagId}", featureFlagId)); - if (endpoint != null) { string featureFlagReference = $"{endpoint.AbsoluteUri}kv/{setting.Key}{(!string.IsNullOrWhiteSpace(setting.Label) ? $"?label={setting.Label}" : "")}"; @@ -1373,19 +1369,5 @@ private FeatureTelemetry ParseFeatureTelemetry(ref Utf8JsonReader reader, string return featureTelemetry; } - - private static string CalculateFeatureFlagId(string key, string label) - { - byte[] featureFlagIdHash; - - // Convert the value consisting of key, newline character, and label to a byte array using UTF8 encoding to hash it using SHA 256 - using (HashAlgorithm hashAlgorithm = SHA256.Create()) - { - featureFlagIdHash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes($"{key}\n{(string.IsNullOrWhiteSpace(label) ? null : label)}")); - } - - // Convert the hashed byte array to Base64Url - return featureFlagIdHash.ToBase64Url(); - } } } diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 41e95959..929bcbc5 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -15,8 +15,6 @@ using System.Collections.Generic; using System.Diagnostics.Tracing; using System.Linq; -using System.Security.Cryptography; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -2151,19 +2149,6 @@ public void WithTelemetry() Assert.Equal("Tag2Value", config["feature_management:feature_flags:0:telemetry:metadata:Tags.Tag2"]); Assert.Equal("c3c231fd-39a0-4cb6-3237-4614474b92c1", config["feature_management:feature_flags:0:telemetry:metadata:ETag"]); - byte[] featureFlagIdHash; - - using (HashAlgorithm hashAlgorithm = SHA256.Create()) - { - featureFlagIdHash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes($"{FeatureManagementConstants.FeatureFlagMarker}TelemetryFeature1\nlabel")); - } - - string featureFlagId = Convert.ToBase64String(featureFlagIdHash) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - - Assert.Equal(featureFlagId, config["feature_management:feature_flags:0:telemetry:metadata:FeatureFlagId"]); Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}TelemetryFeature1?label=label", config["feature_management:feature_flags:0:telemetry:metadata:FeatureFlagReference"]); Assert.Equal("True", config["feature_management:feature_flags:1:telemetry:enabled"]); @@ -2189,24 +2174,10 @@ public void WithAllocationId() }) .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"]); @@ -2223,8 +2194,6 @@ public void WithAllocationId() 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"]); From e09cb23855a36843f8381b7eb172139a6553f0f1 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Fri, 2 May 2025 11:26:31 -0700 Subject: [PATCH 10/37] Add support for filtering by tags (#637) * Adding allocation id * serialize with sorted keys * use string empty * nit * rename ff id to TelemetryVariantPercentile * add more values * dotnet format * Version bump * 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 * 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 * first draft tag filtering support * add alternate APIs * change to use ienumerable * update featureflagoptions to match main options * update keyvalueselector equals and hashcode * update param comments for selects * fix merge conflict errors * add validation for tagsfilter param, add to comment * edit error message for format * edit comment * add unit tests * remove unused file * revert versions * update tests to include feature flag select * add refresh test * ff only refresh test * update equals for selector * fix equals * update equals * reorder properties in keyvalueselector * upgrade to 8.2.0-preview (#638) * fix incorrect test * fix equals for selector * update gethashcode for keyvalueselector * PR comments, in progress * update tests from PR comments * add validation for number of tags, add test * rename tagsFilter to tagsFilters everywhere * fix usings, missing updates to ffoptions * update ffoptions select again * fix tests * update sdk version * update tagsfilters to tagfilters * remove tagsfilters again * PR comments * PR comments * Revert "Merge pull request #600 from Azure/rossgrambo/allocation_id" This reverts commit 51d4ad729a09e6e1efcfe922c302c573b10f8700, reversing changes made to d5515369a5ef31435352719b361b8866043093a9. * Revert "Give the users the ability to have control over ConfigurationClient instance(s) used by the provider (#598) (#617)" This reverts commit 6dc9ae2a42926952eb21339526264407bba5d5ee. --------- Co-authored-by: Ross Grambo Co-authored-by: Sami Sadfa Co-authored-by: Sami Sadfa <71456174+samsadsam@users.noreply.github.com> Co-authored-by: Sami Sadfa --- .../AzureAppConfigurationOptions.cs | 1054 +++++++++-------- .../AzureAppConfigurationProvider.cs | 9 + .../FeatureManagement/FeatureFlagOptions.cs | 21 +- ...Configuration.AzureAppConfiguration.csproj | 4 +- .../Models/KeyValueSelector.cs | 34 +- .../Models/KeyValueWatcher.cs | 6 + .../TagValue.cs | 16 + .../TagFiltersTests.cs | 629 ++++++++++ 8 files changed, 1250 insertions(+), 523 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs create mode 100644 tests/Tests.AzureAppConfiguration/TagFiltersTests.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 6e600fa2..975f1ab3 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -1,517 +1,537 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Azure.Core; -using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration -{ - /// - /// Options used to configure the behavior of an Azure App Configuration provider. - /// If neither nor is ever called, all key-values with no label are included in the configuration provider. - /// - public class AzureAppConfigurationOptions - { - private const int MaxRetries = 2; - private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); - private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; - - private List _individualKvWatchers = new List(); - private List _ffWatchers = new List(); - private List _adapters; - private List>> _mappers = 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. - private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); - - /// - /// Flag to indicate whether replica discovery is enabled. - /// - public bool ReplicaDiscoveryEnabled { get; set; } = true; - - /// - /// Flag to indicate whether load balancing is enabled. - /// - public bool LoadBalancingEnabled { get; set; } - - /// - /// The list of connection strings used to connect to an Azure App Configuration store and its replicas. - /// - internal IEnumerable ConnectionStrings { get; private set; } - - /// - /// The list of endpoints of an Azure App Configuration store. - /// If this property is set, the property also needs to be set. - /// - internal IEnumerable Endpoints { get; private set; } - - /// - /// The credential used to connect to the Azure App Configuration. - /// If this property is set, the property also needs to be set. - /// - internal TokenCredential Credential { get; private set; } - - /// - /// A collection of specified by user. - /// - 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 IndividualKvWatchers => _individualKvWatchers; - - /// - /// A collection of . - /// - internal IEnumerable FeatureFlagWatchers => _ffWatchers; - - /// - /// A collection of . - /// - internal IEnumerable Adapters - { - get => _adapters; - set => _adapters = value?.ToList(); - } - - /// - /// A collection of user defined functions that transform each . - /// - internal IEnumerable>> Mappers => _mappers; - - /// - /// A collection of key prefixes to be trimmed. - /// - internal IEnumerable KeyPrefixes => _keyPrefixes; - - /// - /// For use in tests only. An optional configuration client manager that can be used to provide clients to communicate with Azure App Configuration. - /// - 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. - /// - internal TimeSpan MinBackoffDuration { get; set; } = FailOverConstants.MinBackoffDuration; - - /// - /// Options used to configure the client used to communicate with Azure App Configuration. - /// - internal ConfigurationClientOptions ClientOptions { get; } = GetDefaultClientOptions(); - - /// - /// Flag to indicate whether Key Vault options have been configured. - /// - internal bool IsKeyVaultConfigured { get; private set; } = false; - - /// - /// Flag to indicate whether Key Vault secret values will be refreshed automatically. - /// - internal bool IsKeyVaultRefreshConfigured { get; private set; } = false; - - /// - /// Indicates all feature flag features used by the application. - /// - internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); - - /// - /// Options used to configure provider startup. - /// - internal StartupOptions Startup { get; set; } = new StartupOptions(); - - /// - /// Initializes a new instance of the class. - /// - public AzureAppConfigurationOptions() - { - _adapters = new List() - { - new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()), - new JsonKeyValueAdapter(), - new FeatureManagementKeyValueAdapter(FeatureFlagTracing) - }; - - // Adds the default query to App Configuration if and are never called. - _selectors = new List { DefaultQuery }; - } - - /// - /// 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: . - /// - /// - /// The label filter to apply when querying Azure App Configuration for key-values. 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 AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null) - { - if (string.IsNullOrEmpty(keyFilter)) - { - 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; - } - - if (!_selectCalled) - { - _selectors.Remove(DefaultQuery); - - _selectCalled = true; - } - - _selectors.AppendUnique(new KeyValueSelector - { - KeyFilter = keyFilter, - LabelFilter = labelFilter - }); - - return this; - } - - /// - /// Specify a snapshot and include its contained key-values in the configuration provider. - /// can be called multiple times to include key-values from multiple snapshots. - /// - /// The name of the snapshot in Azure App Configuration. - public AzureAppConfigurationOptions SelectSnapshot(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException(nameof(name)); - } - - if (!_selectCalled) - { - _selectors.Remove(DefaultQuery); - - _selectCalled = true; - } - - _selectors.AppendUnique(new KeyValueSelector - { - SnapshotName = name - }); - - return this; - } - - /// - /// 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 as a collection. - /// - /// A callback used to configure feature flag options. - public AzureAppConfigurationOptions UseFeatureFlags(Action configure = null) - { - FeatureFlagOptions options = new FeatureFlagOptions(); - configure?.Invoke(options); - - if (options.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval) - { - throw new ArgumentOutOfRangeException(nameof(options.RefreshInterval), options.RefreshInterval.TotalMilliseconds, - string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds)); - } - - if (options.FeatureFlagSelectors.Count() != 0 && options.Label != null) - { - throw new InvalidOperationException($"Please select feature flags by either the {nameof(options.Select)} method or by setting the {nameof(options.Label)} property, not both."); - } - - if (options.FeatureFlagSelectors.Count() == 0) - { - // Select clause is not present - options.FeatureFlagSelectors.Add(new KeyValueSelector - { - KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", - LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, - IsFeatureFlagSelector = true - }); - } - - foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) - { - _selectors.AppendUnique(featureFlagSelector); - - _ffWatchers.AppendUnique(new KeyValueWatcher - { - 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; - } - - /// - /// Connect the provider to the Azure App Configuration service via a connection string. - /// - /// - /// Used to authenticate with Azure App Configuration. - /// - public AzureAppConfigurationOptions Connect(string connectionString) - { - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new ArgumentNullException(nameof(connectionString)); - } - - return Connect(new List { connectionString }); - } - - /// - /// Connect the provider to an Azure App Configuration store and its replicas via a list of connection strings. - /// - /// - /// Used to authenticate with Azure App Configuration. - /// - public AzureAppConfigurationOptions Connect(IEnumerable connectionStrings) - { - if (connectionStrings == null || !connectionStrings.Any()) - { - throw new ArgumentNullException(nameof(connectionStrings)); - } - - if (connectionStrings.Distinct().Count() != connectionStrings.Count()) - { - throw new ArgumentException($"All values in '{nameof(connectionStrings)}' must be unique."); - } - - Endpoints = null; - Credential = null; - ConnectionStrings = connectionStrings; - return this; - } - - /// - /// Connect the provider to Azure App Configuration using endpoint and token credentials. - /// - /// The endpoint of the Azure App Configuration to connect to. - /// Token credentials to use to connect. - public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential credential) - { - if (endpoint == null) - { - throw new ArgumentNullException(nameof(endpoint)); - } - - if (credential == null) - { - throw new ArgumentNullException(nameof(credential)); - } - - return Connect(new List() { endpoint }, credential); - } - - /// - /// Connect the provider to an Azure App Configuration store and its replicas using a list of endpoints and a token credential. - /// - /// The list of endpoints of an Azure App Configuration store and its replicas to connect to. - /// Token credential to use to connect. - public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCredential credential) - { - if (endpoints == null || !endpoints.Any()) - { - throw new ArgumentNullException(nameof(endpoints)); - } - - if (endpoints.Distinct(new EndpointComparer()).Count() != endpoints.Count()) - { - throw new ArgumentException($"All values in '{nameof(endpoints)}' must be unique."); - } - - Credential = credential ?? throw new ArgumentNullException(nameof(credential)); - - Endpoints = endpoints; - ConnectionStrings = null; - return this; - } - - /// - /// Trims the provided prefix from the keys of all key-values retrieved from Azure App Configuration. - /// - /// The prefix to be trimmed. - public AzureAppConfigurationOptions TrimKeyPrefix(string prefix) - { - if (string.IsNullOrEmpty(prefix)) - { - throw new ArgumentNullException(nameof(prefix)); - } - - _keyPrefixes.Add(prefix); - return this; - } - - /// - /// Configure the client(s) used to communicate with Azure App Configuration. - /// - /// A callback used to configure Azure App Configuration client options. - public AzureAppConfigurationOptions ConfigureClientOptions(Action configure) - { - configure?.Invoke(ClientOptions); - return this; - } - - /// - /// Configure refresh for key-values in the configuration provider. - /// - /// A 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); - - 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 InvalidOperationException($"Cannot call both {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} and " - + $"{nameof(AzureAppConfigurationRefreshOptions.Register)}."); - } - - if (RegisterAllEnabled) - { - KvCollectionRefreshInterval = refreshOptions.RefreshInterval; - } - else - { - foreach (KeyValueWatcher item in refreshOptions.RefreshRegistrations) - { - item.RefreshInterval = refreshOptions.RefreshInterval; - _individualKvWatchers.Add(item); - } - } - - return this; - } - - /// - /// Get an instance of that can be used to trigger a refresh for the registered key-values. - /// - /// An instance of . - public IConfigurationRefresher GetRefresher() - { - return _refresher; - } - - /// - /// Configures the Azure App Configuration provider to use the provided Key Vault configuration to resolve key vault references. - /// - /// A callback used to configure Azure App Configuration key vault options. - public AzureAppConfigurationOptions ConfigureKeyVault(Action configure) - { - var keyVaultOptions = new AzureAppConfigurationKeyVaultOptions(); - configure?.Invoke(keyVaultOptions); - - if (keyVaultOptions.Credential != null && keyVaultOptions.SecretResolver != null) - { - throw new InvalidOperationException($"Cannot configure both default credentials and secret resolver for Key Vault references. Please call either {nameof(keyVaultOptions.SetCredential)} or {nameof(keyVaultOptions.SetSecretResolver)} method, not both."); - } - - _adapters.RemoveAll(a => a is AzureKeyVaultKeyValueAdapter); - _adapters.Add(new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider(keyVaultOptions))); - - IsKeyVaultRefreshConfigured = keyVaultOptions.IsKeyVaultRefreshConfigured; - IsKeyVaultConfigured = true; - return this; - } - - /// - /// Provides a way to transform settings retrieved from App Configuration before they are processed by the configuration provider. - /// - /// A callback registered by the user to transform each configuration setting. - public AzureAppConfigurationOptions Map(Func> mapper) - { - if (mapper == null) - { - throw new ArgumentNullException(nameof(mapper)); - } - - _mappers.Add(mapper); - return this; - } - - /// - /// Configure the provider behavior when loading data from Azure App Configuration on startup. - /// - /// A callback used to configure Azure App Configuration startup options. - public AzureAppConfigurationOptions ConfigureStartupOptions(Action configure) - { - configure?.Invoke(Startup); - return this; - } - - private static ConfigurationClientOptions GetDefaultClientOptions() - { - var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_10_01); - clientOptions.Retry.MaxRetries = MaxRetries; - clientOptions.Retry.MaxDelay = MaxRetryDelay; - clientOptions.Retry.Mode = RetryMode.Exponential; - clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); - - return clientOptions; - } - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Core; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + /// + /// Options used to configure the behavior of an Azure App Configuration provider. + /// If neither nor is ever called, all key-values with no label are included in the configuration provider. + /// + public class AzureAppConfigurationOptions + { + private const int MaxRetries = 2; + private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); + private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; + + private List _individualKvWatchers = new List(); + private List _ffWatchers = new List(); + private List _adapters; + private List>> _mappers = 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. + private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); + + /// + /// Flag to indicate whether replica discovery is enabled. + /// + public bool ReplicaDiscoveryEnabled { get; set; } = true; + + /// + /// Flag to indicate whether load balancing is enabled. + /// + public bool LoadBalancingEnabled { get; set; } + + /// + /// The list of connection strings used to connect to an Azure App Configuration store and its replicas. + /// + internal IEnumerable ConnectionStrings { get; private set; } + + /// + /// The list of endpoints of an Azure App Configuration store. + /// If this property is set, the property also needs to be set. + /// + internal IEnumerable Endpoints { get; private set; } + + /// + /// The credential used to connect to the Azure App Configuration. + /// If this property is set, the property also needs to be set. + /// + internal TokenCredential Credential { get; private set; } + + /// + /// A collection of specified by user. + /// + 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 IndividualKvWatchers => _individualKvWatchers; + + /// + /// A collection of . + /// + internal IEnumerable FeatureFlagWatchers => _ffWatchers; + + /// + /// A collection of . + /// + internal IEnumerable Adapters + { + get => _adapters; + set => _adapters = value?.ToList(); + } + + /// + /// A collection of user defined functions that transform each . + /// + internal IEnumerable>> Mappers => _mappers; + + /// + /// A collection of key prefixes to be trimmed. + /// + internal IEnumerable KeyPrefixes => _keyPrefixes; + + /// + /// For use in tests only. An optional configuration client manager that can be used to provide clients to communicate with Azure App Configuration. + /// + 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. + /// + internal TimeSpan MinBackoffDuration { get; set; } = FailOverConstants.MinBackoffDuration; + + /// + /// Options used to configure the client used to communicate with Azure App Configuration. + /// + internal ConfigurationClientOptions ClientOptions { get; } = GetDefaultClientOptions(); + + /// + /// Flag to indicate whether Key Vault options have been configured. + /// + internal bool IsKeyVaultConfigured { get; private set; } = false; + + /// + /// Flag to indicate whether Key Vault secret values will be refreshed automatically. + /// + internal bool IsKeyVaultRefreshConfigured { get; private set; } = false; + + /// + /// Indicates all feature flag features used by the application. + /// + internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); + + /// + /// Options used to configure provider startup. + /// + internal StartupOptions Startup { get; set; } = new StartupOptions(); + + /// + /// Initializes a new instance of the class. + /// + public AzureAppConfigurationOptions() + { + _adapters = new List() + { + new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()), + new JsonKeyValueAdapter(), + new FeatureManagementKeyValueAdapter(FeatureFlagTracing) + }; + + // Adds the default query to App Configuration if and are never called. + _selectors = new List { DefaultQuery }; + } + + /// + /// 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: . + /// + /// + /// The label filter to apply when querying Azure App Configuration for key-values. 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 AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable tagFilters = null) + { + if (string.IsNullOrEmpty(keyFilter)) + { + 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; + } + + if (tagFilters != null) + { + foreach (string 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(tagFilters)); + } + } + } + + if (!_selectCalled) + { + _selectors.Remove(DefaultQuery); + + _selectCalled = true; + } + + _selectors.AppendUnique(new KeyValueSelector + { + KeyFilter = keyFilter, + LabelFilter = labelFilter, + TagFilters = tagFilters + }); + + return this; + } + + /// + /// Specify a snapshot and include its contained key-values in the configuration provider. + /// can be called multiple times to include key-values from multiple snapshots. + /// + /// The name of the snapshot in Azure App Configuration. + public AzureAppConfigurationOptions SelectSnapshot(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + if (!_selectCalled) + { + _selectors.Remove(DefaultQuery); + + _selectCalled = true; + } + + _selectors.AppendUnique(new KeyValueSelector + { + SnapshotName = name + }); + + return this; + } + + /// + /// 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 as a collection. + /// + /// A callback used to configure feature flag options. + public AzureAppConfigurationOptions UseFeatureFlags(Action configure = null) + { + FeatureFlagOptions options = new FeatureFlagOptions(); + configure?.Invoke(options); + + if (options.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval) + { + throw new ArgumentOutOfRangeException(nameof(options.RefreshInterval), options.RefreshInterval.TotalMilliseconds, + string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds)); + } + + if (options.FeatureFlagSelectors.Count() != 0 && options.Label != null) + { + throw new InvalidOperationException($"Please select feature flags by either the {nameof(options.Select)} method or by setting the {nameof(options.Label)} property, not both."); + } + + if (options.FeatureFlagSelectors.Count() == 0) + { + // Select clause is not present + options.FeatureFlagSelectors.Add(new KeyValueSelector + { + KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", + LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, + IsFeatureFlagSelector = true + }); + } + + foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) + { + _selectors.AppendUnique(featureFlagSelector); + + _ffWatchers.AppendUnique(new KeyValueWatcher + { + Key = featureFlagSelector.KeyFilter, + Label = featureFlagSelector.LabelFilter, + Tags = featureFlagSelector.TagFilters, + // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins + RefreshInterval = options.RefreshInterval + }); + } + + return this; + } + + /// + /// Connect the provider to the Azure App Configuration service via a connection string. + /// + /// + /// Used to authenticate with Azure App Configuration. + /// + public AzureAppConfigurationOptions Connect(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + return Connect(new List { connectionString }); + } + + /// + /// Connect the provider to an Azure App Configuration store and its replicas via a list of connection strings. + /// + /// + /// Used to authenticate with Azure App Configuration. + /// + public AzureAppConfigurationOptions Connect(IEnumerable connectionStrings) + { + if (connectionStrings == null || !connectionStrings.Any()) + { + throw new ArgumentNullException(nameof(connectionStrings)); + } + + if (connectionStrings.Distinct().Count() != connectionStrings.Count()) + { + throw new ArgumentException($"All values in '{nameof(connectionStrings)}' must be unique."); + } + + Endpoints = null; + Credential = null; + ConnectionStrings = connectionStrings; + return this; + } + + /// + /// Connect the provider to Azure App Configuration using endpoint and token credentials. + /// + /// The endpoint of the Azure App Configuration to connect to. + /// Token credentials to use to connect. + public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential credential) + { + if (endpoint == null) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + if (credential == null) + { + throw new ArgumentNullException(nameof(credential)); + } + + return Connect(new List() { endpoint }, credential); + } + + /// + /// Connect the provider to an Azure App Configuration store and its replicas using a list of endpoints and a token credential. + /// + /// The list of endpoints of an Azure App Configuration store and its replicas to connect to. + /// Token credential to use to connect. + public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCredential credential) + { + if (endpoints == null || !endpoints.Any()) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + if (endpoints.Distinct(new EndpointComparer()).Count() != endpoints.Count()) + { + throw new ArgumentException($"All values in '{nameof(endpoints)}' must be unique."); + } + + Credential = credential ?? throw new ArgumentNullException(nameof(credential)); + + Endpoints = endpoints; + ConnectionStrings = null; + return this; + } + + /// + /// Trims the provided prefix from the keys of all key-values retrieved from Azure App Configuration. + /// + /// The prefix to be trimmed. + public AzureAppConfigurationOptions TrimKeyPrefix(string prefix) + { + if (string.IsNullOrEmpty(prefix)) + { + throw new ArgumentNullException(nameof(prefix)); + } + + _keyPrefixes.Add(prefix); + return this; + } + + /// + /// Configure the client(s) used to communicate with Azure App Configuration. + /// + /// A callback used to configure Azure App Configuration client options. + public AzureAppConfigurationOptions ConfigureClientOptions(Action configure) + { + configure?.Invoke(ClientOptions); + return this; + } + + /// + /// Configure refresh for key-values in the configuration provider. + /// + /// A 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); + + 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 InvalidOperationException($"Cannot call both {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} and " + + $"{nameof(AzureAppConfigurationRefreshOptions.Register)}."); + } + + if (RegisterAllEnabled) + { + KvCollectionRefreshInterval = refreshOptions.RefreshInterval; + } + else + { + foreach (KeyValueWatcher item in refreshOptions.RefreshRegistrations) + { + item.RefreshInterval = refreshOptions.RefreshInterval; + _individualKvWatchers.Add(item); + } + } + + return this; + } + + /// + /// Get an instance of that can be used to trigger a refresh for the registered key-values. + /// + /// An instance of . + public IConfigurationRefresher GetRefresher() + { + return _refresher; + } + + /// + /// Configures the Azure App Configuration provider to use the provided Key Vault configuration to resolve key vault references. + /// + /// A callback used to configure Azure App Configuration key vault options. + public AzureAppConfigurationOptions ConfigureKeyVault(Action configure) + { + var keyVaultOptions = new AzureAppConfigurationKeyVaultOptions(); + configure?.Invoke(keyVaultOptions); + + if (keyVaultOptions.Credential != null && keyVaultOptions.SecretResolver != null) + { + throw new InvalidOperationException($"Cannot configure both default credentials and secret resolver for Key Vault references. Please call either {nameof(keyVaultOptions.SetCredential)} or {nameof(keyVaultOptions.SetSecretResolver)} method, not both."); + } + + _adapters.RemoveAll(a => a is AzureKeyVaultKeyValueAdapter); + _adapters.Add(new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider(keyVaultOptions))); + + IsKeyVaultRefreshConfigured = keyVaultOptions.IsKeyVaultRefreshConfigured; + IsKeyVaultConfigured = true; + return this; + } + + /// + /// Provides a way to transform settings retrieved from App Configuration before they are processed by the configuration provider. + /// + /// A callback registered by the user to transform each configuration setting. + public AzureAppConfigurationOptions Map(Func> mapper) + { + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + _mappers.Add(mapper); + return this; + } + + /// + /// Configure the provider behavior when loading data from Azure App Configuration on startup. + /// + /// A callback used to configure Azure App Configuration startup options. + public AzureAppConfigurationOptions ConfigureStartupOptions(Action configure) + { + configure?.Invoke(Startup); + return this; + } + + private static ConfigurationClientOptions GetDefaultClientOptions() + { + var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_10_01); + clientOptions.Retry.MaxRetries = MaxRetries; + clientOptions.Retry.MaxDelay = MaxRetryDelay; + clientOptions.Retry.Mode = RetryMode.Exponential; + clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); + + return clientOptions; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index d1e18edb..28e2d507 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -346,6 +346,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { KeyFilter = watcher.Key, LabelFilter = watcher.Label, + TagFilters = watcher.Tags, IsFeatureFlagSelector = true }), _ffEtags, @@ -828,6 +829,14 @@ private async Task> LoadSelected( LabelFilter = loadOption.LabelFilter }; + if (loadOption.TagFilters != null) + { + foreach (string tagFilter in loadOption.TagFilters) + { + 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 26390762..4b6d56d6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -74,7 +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 (\). /// - public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null) + /// + /// 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, feature flags will not be filtered based on tags. + /// + public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IEnumerable tagFilters = null) { if (string.IsNullOrEmpty(featureFlagFilter)) { @@ -97,12 +104,24 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); } + if (tagFilters != null) + { + foreach (string 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(tagFilters)); + } + } + } + string featureFlagPrefix = FeatureManagementConstants.FeatureFlagMarker + featureFlagFilter; FeatureFlagSelectors.AppendUnique(new KeyValueSelector { KeyFilter = featureFlagPrefix, LabelFilter = labelFilter, + TagFilters = tagFilters, 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 d5af2b14..b83cf2e2 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -15,10 +15,12 @@ - + + + diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 54bda1a4..f01eb655 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System; +using System.Collections.Generic; +using System.Linq; + namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Models { /// @@ -24,6 +28,11 @@ public class KeyValueSelector /// public string SnapshotName { get; set; } + /// + /// A filter that determines what tags to require when selecting key-values for the the configuration provider. + /// + public IEnumerable TagFilters { get; set; } + /// /// A boolean that signifies whether this selector is intended to select feature flags. /// @@ -40,7 +49,11 @@ public override bool Equals(object obj) { return KeyFilter == selector.KeyFilter && LabelFilter == selector.LabelFilter - && SnapshotName == selector.SnapshotName; + && SnapshotName == selector.SnapshotName + && (TagFilters == null + ? selector.TagFilters == null + : selector.TagFilters != null && new HashSet(TagFilters).SetEquals(selector.TagFilters)) + && IsFeatureFlagSelector == selector.IsFeatureFlagSelector; } return false; @@ -52,9 +65,22 @@ public override bool Equals(object obj) /// A hash code for the current object. public override int GetHashCode() { - return (KeyFilter?.GetHashCode() ?? 0) ^ - (LabelFilter?.GetHashCode() ?? 1) ^ - (SnapshotName?.GetHashCode() ?? 2); + string tagFiltersString = string.Empty; + + if (TagFilters != null && TagFilters.Any()) + { + var sortedTags = new SortedSet(TagFilters); + + // Concatenate tags into a single string with a delimiter + tagFiltersString = string.Join("\n", sortedTags); + } + + return HashCode.Combine( + KeyFilter, + LabelFilter, + SnapshotName, + tagFiltersString, + IsFeatureFlagSelector); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs index 616f8bcd..a9f59e74 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs @@ -3,6 +3,7 @@ // using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using System; +using System.Collections.Generic; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Models { @@ -18,6 +19,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 00000000..7522e7e1 --- /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 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/TagFiltersTests.cs b/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs new file mode 100644 index 00000000..f0e4e263 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs @@ -0,0 +1,629 @@ +// 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 Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using Moq; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Tests.AzureAppConfiguration +{ + public class TagFiltersTests + { + private List _kvCollection; + private const int MaxTagFilters = 5; + + public TagFiltersTests() + { + _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" }, + { "EmptyTag", "" }, + { "NullTag", null } + }), + + 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" }, + { "EmptyTag", "" }, + { "NullTag", null } + }), + }; + } + + 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; + } + + 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 TagFiltersTests_BasicTagFiltering() + { + 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" }); + options.UseFeatureFlags(ff => + { + ff.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"]); + + 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] + public void TagFiltersTests_NullOrEmptyValue() + { + 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 TagFiltersTests_MultipleTagsFiltering() + { + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains("App=TestApp") && + 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["Environment"] == "Development"))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "App=TestApp", "Environment=Development" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "App=TestApp", "Environment=Development" }); + }); + }) + .Build(); + + Assert.Equal("TestValue1", config["TestKey1"]); + 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.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] + public void TagFiltersTests_InvalidTagFormat() + { + var mockClient = new Mock(MockBehavior.Strict); + + List invalidTagFilters = new List { "InvalidTagFormat", "=tagValue", "", null }; + + foreach (string tagsFilter in invalidTagFilters) + { + // 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 filter '{tagsFilter}' does not follow the format \"tagName=tagValue\".", exception.Message); + } + } + + [Fact] + 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 <= 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 > MaxTagFilters), + It.IsAny())) + .Throws(new RequestFailedException($"Invalid parameter TagsFilter. Maximum filters is {MaxTagFilters}")); + + 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 + { + "Environment=Development", + "Environment=Development", + "Environment=Development", + "Environment=Development", + "Environment=Development", + "Environment=Development" + }; + + // Verify that a RequestFailedException 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(); + }); + } + + [Fact] + public void TagFiltersTests_TagFilterInteractionWithKeyLabelFilters() + { + 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.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(FeatureManagementConstants.FeatureFlagMarker + "Feature1")) && + 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" }); + options.UseFeatureFlags(ff => + { + ff.Select("Feature1", "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"]); + + 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] + public void TagFiltersTests_EmptyTagsCollection() + { + 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()); + options.UseFeatureFlags(ff => + { + ff.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"]); + 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] + public void TagFiltersTests_SpecialCharactersInTags() + { + 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" }); + options.UseFeatureFlags(ff => + { + ff.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"]); + + 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] + public void TagFiltersTests_EscapedCommaCharactersInTags() + { + 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" }); + options.UseFeatureFlags(ff => + { + ff.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"]); + + 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"]); + } + + [Fact] + public async Task TagFiltersTests_BasicRefresh() + { + 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"]); + + _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 17ed39c56051206ce17e9342d90c7f663d677fa3 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Wed, 7 May 2025 10:27:27 -0700 Subject: [PATCH 11/37] Clarify client factory comment (#660) * add comment to setclientfactory * update comment * update comment --- .../AzureAppConfigurationOptions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 25180357..3655bf7d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -174,6 +174,9 @@ public AzureAppConfigurationOptions() /// /// Sets the client factory used to create ConfigurationClient instances. + /// If a client factory is provided using this method, a call to Connect is + /// still required to identify one or more Azure App Configuration stores but + /// will not be used to authenticate a . /// /// The client factory. /// The current instance. From 22af6e2ed71eaeadf20c4e35b29896834769c464 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Fri, 9 May 2025 14:18:29 -0700 Subject: [PATCH 12/37] Shorten default timeout of individual calls to backend (#657) * in progress fix shorten timeout PR * dispose httpclienttransport * remove unnecessary check * fix disposal pattern * fix static compile error * remove unused using * reset options * fix options * add line to options * use retryoptions.networktimeout * add test, update isfailoverable * update test comment * update test * remove check for nested taskcanceledexception * simplify if statement in isfailoverable --- .../AzureAppConfigurationOptions.cs | 2 + .../AzureAppConfigurationProvider.cs | 5 ++ .../FailoverTests.cs | 79 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 975f1ab3..9b33c133 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -22,6 +22,7 @@ public class AzureAppConfigurationOptions { private const int MaxRetries = 2; 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 }; private List _individualKvWatchers = new List(); @@ -529,6 +530,7 @@ private static ConfigurationClientOptions GetDefaultClientOptions() clientOptions.Retry.MaxRetries = MaxRetries; clientOptions.Retry.MaxDelay = MaxRetryDelay; clientOptions.Retry.Mode = RetryMode.Exponential; + clientOptions.Retry.NetworkTimeout = NetworkTimeout; clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); return clientOptions; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 28e2d507..a83c7413 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -1232,6 +1232,11 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => private bool IsFailOverable(AggregateException ex) { + if (ex.InnerExceptions?.Any(e => e is TaskCanceledException) == true) + { + return true; + } + RequestFailedException rfe = ex.InnerExceptions?.LastOrDefault(e => e is RequestFailedException) as RequestFailedException; return rfe != null ? IsFailOverable(rfe) : false; diff --git a/tests/Tests.AzureAppConfiguration/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/FailoverTests.cs index 86ea96b9..105f267b 100644 --- a/tests/Tests.AzureAppConfiguration/FailoverTests.cs +++ b/tests/Tests.AzureAppConfiguration/FailoverTests.cs @@ -337,5 +337,84 @@ public void FailOverTests_GetNoDynamicClient() // Only contains the client that passed while constructing the ConfigurationClientManager Assert.Single(clients); } + + [Fact] + public void FailOverTests_NetworkTimeout() + { + // Arrange + IConfigurationRefresher refresher = null; + var mockResponse = new Mock(); + + var client1 = new ConfigurationClient(TestHelpers.CreateMockEndpointString(), + new ConfigurationClientOptions() + { + Retry = + { + NetworkTimeout = TimeSpan.FromTicks(1) + } + }); + + var mockClient2 = new Mock(); + mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); + + ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, client1); + ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + + var clientList = new List() { cw1 }; + var autoFailoverList = new List() { cw2 }; + var configClientManager = new MockedConfigurationClientManager(clientList, autoFailoverList); + + // Make sure the provider fails over and will load correctly using the second client + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = configClientManager; + options.Select("TestKey*"); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + }) + .Build(); + + // Make sure the provider fails on startup and throws the expected exception due to startup timeout + Exception exception = Assert.Throws(() => + { + config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(TestHelpers.CreateMockEndpointString()); + options.Select("TestKey*"); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + options.ConfigureStartupOptions(startup => + { + startup.Timeout = TimeSpan.FromSeconds(5); + }); + options.ConfigureClientOptions(clientOptions => + { + clientOptions.Retry.NetworkTimeout = TimeSpan.FromTicks(1); + }); + }) + .Build(); + }); + + // Make sure the startup exception is due to network timeout + // Aggregate exception is nested due to how provider stores all startup exceptions thrown + Assert.True(exception.InnerException is AggregateException ae && + ae.InnerException is AggregateException ae2 && + ae2.InnerExceptions.All(ex => ex is TaskCanceledException) && + ae2.InnerException is TaskCanceledException tce); + } } } From a2c0bee5f5e7859b658545762abbd129d5d33c26 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Fri, 9 May 2025 14:36:48 -0700 Subject: [PATCH 13/37] renormalize options --- .../AzureAppConfigurationOptions.cs | 1064 ++++++++--------- 1 file changed, 532 insertions(+), 532 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index fc632c33..1e79daba 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -1,208 +1,208 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -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; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration -{ - /// - /// Options used to configure the behavior of an Azure App Configuration provider. - /// If neither nor is ever called, all key-values with no label are included in the configuration provider. - /// - public class AzureAppConfigurationOptions - { - private const int MaxRetries = 2; - private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +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; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + /// + /// Options used to configure the behavior of an Azure App Configuration provider. + /// If neither nor is ever called, all key-values with no label are included in the configuration provider. + /// + public class AzureAppConfigurationOptions + { + private const int MaxRetries = 2; + 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 }; - - private List _individualKvWatchers = new List(); - private List _ffWatchers = new List(); - private List _adapters; - private List>> _mappers = 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. - private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); - - /// - /// Flag to indicate whether replica discovery is enabled. - /// - public bool ReplicaDiscoveryEnabled { get; set; } = true; - - /// - /// Flag to indicate whether load balancing is enabled. - /// - public bool LoadBalancingEnabled { get; set; } - - /// - /// The list of connection strings used to connect to an Azure App Configuration store and its replicas. - /// - internal IEnumerable ConnectionStrings { get; private set; } - - /// - /// The list of endpoints of an Azure App Configuration store. - /// If this property is set, the property also needs to be set. - /// - internal IEnumerable Endpoints { get; private set; } - - /// - /// The credential used to connect to the Azure App Configuration. - /// If this property is set, the property also needs to be set. - /// - internal TokenCredential Credential { get; private set; } - - /// - /// A collection of specified by user. - /// - 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 IndividualKvWatchers => _individualKvWatchers; - - /// - /// A collection of . - /// - internal IEnumerable FeatureFlagWatchers => _ffWatchers; - - /// - /// A collection of . - /// - internal IEnumerable Adapters - { - get => _adapters; - set => _adapters = value?.ToList(); - } - - /// - /// A collection of user defined functions that transform each . - /// - internal IEnumerable>> Mappers => _mappers; - - /// - /// A collection of key prefixes to be trimmed. - /// - internal IEnumerable KeyPrefixes => _keyPrefixes; - - /// - /// For use in tests only. An optional configuration client manager that can be used to provide clients to communicate with Azure App Configuration. - /// - 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. - /// - internal TimeSpan MinBackoffDuration { get; set; } = FailOverConstants.MinBackoffDuration; - - /// - /// Options used to configure the client used to communicate with Azure App Configuration. - /// - internal ConfigurationClientOptions ClientOptions { get; } = GetDefaultClientOptions(); - - /// - /// Flag to indicate whether Key Vault options have been configured. - /// - internal bool IsKeyVaultConfigured { get; private set; } = false; - - /// - /// Flag to indicate whether Key Vault secret values will be refreshed automatically. - /// - internal bool IsKeyVaultRefreshConfigured { get; private set; } = false; - - /// - /// Indicates all feature flag features used by the application. - /// - internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); - - /// - /// Options used to configure provider startup. - /// - 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. - /// - public AzureAppConfigurationOptions() - { - _adapters = new List() - { - new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()), - new JsonKeyValueAdapter(), - new FeatureManagementKeyValueAdapter(FeatureFlagTracing) - }; - - // Adds the default query to App Configuration if and are never called. - _selectors = new List { DefaultQuery }; - } - - /// - /// Sets the client factory used to create ConfigurationClient instances. + private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; + + private List _individualKvWatchers = new List(); + private List _ffWatchers = new List(); + private List _adapters; + private List>> _mappers = 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. + private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); + + /// + /// Flag to indicate whether replica discovery is enabled. + /// + public bool ReplicaDiscoveryEnabled { get; set; } = true; + + /// + /// Flag to indicate whether load balancing is enabled. + /// + public bool LoadBalancingEnabled { get; set; } + + /// + /// The list of connection strings used to connect to an Azure App Configuration store and its replicas. + /// + internal IEnumerable ConnectionStrings { get; private set; } + + /// + /// The list of endpoints of an Azure App Configuration store. + /// If this property is set, the property also needs to be set. + /// + internal IEnumerable Endpoints { get; private set; } + + /// + /// The credential used to connect to the Azure App Configuration. + /// If this property is set, the property also needs to be set. + /// + internal TokenCredential Credential { get; private set; } + + /// + /// A collection of specified by user. + /// + 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 IndividualKvWatchers => _individualKvWatchers; + + /// + /// A collection of . + /// + internal IEnumerable FeatureFlagWatchers => _ffWatchers; + + /// + /// A collection of . + /// + internal IEnumerable Adapters + { + get => _adapters; + set => _adapters = value?.ToList(); + } + + /// + /// A collection of user defined functions that transform each . + /// + internal IEnumerable>> Mappers => _mappers; + + /// + /// A collection of key prefixes to be trimmed. + /// + internal IEnumerable KeyPrefixes => _keyPrefixes; + + /// + /// For use in tests only. An optional configuration client manager that can be used to provide clients to communicate with Azure App Configuration. + /// + 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. + /// + internal TimeSpan MinBackoffDuration { get; set; } = FailOverConstants.MinBackoffDuration; + + /// + /// Options used to configure the client used to communicate with Azure App Configuration. + /// + internal ConfigurationClientOptions ClientOptions { get; } = GetDefaultClientOptions(); + + /// + /// Flag to indicate whether Key Vault options have been configured. + /// + internal bool IsKeyVaultConfigured { get; private set; } = false; + + /// + /// Flag to indicate whether Key Vault secret values will be refreshed automatically. + /// + internal bool IsKeyVaultRefreshConfigured { get; private set; } = false; + + /// + /// Indicates all feature flag features used by the application. + /// + internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); + + /// + /// Options used to configure provider startup. + /// + 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. + /// + public AzureAppConfigurationOptions() + { + _adapters = new List() + { + new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()), + new JsonKeyValueAdapter(), + new FeatureManagementKeyValueAdapter(FeatureFlagTracing) + }; + + // Adds the default query to App Configuration if and are never called. + _selectors = new List { DefaultQuery }; + } + + /// + /// Sets the client factory used to create ConfigurationClient instances. /// If a client factory is provided using this method, a call to Connect is /// still required to identify one or more Azure App Configuration stores but /// will not be used to authenticate a . - /// - /// 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. - /// - /// - /// 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: . - /// - /// - /// The label filter to apply when querying Azure App Configuration for key-values. 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 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. + /// + /// + /// 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: . + /// + /// + /// The label filter to apply when querying Azure App Configuration for key-values. 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. @@ -211,23 +211,23 @@ public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable tagFilters = null) - { - if (string.IsNullOrEmpty(keyFilter)) - { - 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; - } - + { + if (string.IsNullOrEmpty(keyFilter)) + { + 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; + } + if (tagFilters != null) { foreach (string tag in tagFilters) @@ -239,321 +239,321 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter } } - if (!_selectCalled) - { - _selectors.Remove(DefaultQuery); - - _selectCalled = true; - } - - _selectors.AppendUnique(new KeyValueSelector - { - KeyFilter = keyFilter, + if (!_selectCalled) + { + _selectors.Remove(DefaultQuery); + + _selectCalled = true; + } + + _selectors.AppendUnique(new KeyValueSelector + { + KeyFilter = keyFilter, LabelFilter = labelFilter, TagFilters = tagFilters - }); - - return this; - } - - /// - /// Specify a snapshot and include its contained key-values in the configuration provider. - /// can be called multiple times to include key-values from multiple snapshots. - /// - /// The name of the snapshot in Azure App Configuration. - public AzureAppConfigurationOptions SelectSnapshot(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException(nameof(name)); - } - - if (!_selectCalled) - { - _selectors.Remove(DefaultQuery); - - _selectCalled = true; - } - - _selectors.AppendUnique(new KeyValueSelector - { - SnapshotName = name - }); - - return this; - } - - /// - /// 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 as a collection. - /// - /// A callback used to configure feature flag options. - public AzureAppConfigurationOptions UseFeatureFlags(Action configure = null) - { - FeatureFlagOptions options = new FeatureFlagOptions(); - configure?.Invoke(options); - - if (options.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval) - { - throw new ArgumentOutOfRangeException(nameof(options.RefreshInterval), options.RefreshInterval.TotalMilliseconds, - string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds)); - } - - if (options.FeatureFlagSelectors.Count() != 0 && options.Label != null) - { - throw new InvalidOperationException($"Please select feature flags by either the {nameof(options.Select)} method or by setting the {nameof(options.Label)} property, not both."); - } - - if (options.FeatureFlagSelectors.Count() == 0) - { - // Select clause is not present - options.FeatureFlagSelectors.Add(new KeyValueSelector - { - KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", - LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, - IsFeatureFlagSelector = true - }); - } - - foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) - { - _selectors.AppendUnique(featureFlagSelector); - - _ffWatchers.AppendUnique(new KeyValueWatcher - { - Key = featureFlagSelector.KeyFilter, - Label = featureFlagSelector.LabelFilter, + }); + + return this; + } + + /// + /// Specify a snapshot and include its contained key-values in the configuration provider. + /// can be called multiple times to include key-values from multiple snapshots. + /// + /// The name of the snapshot in Azure App Configuration. + public AzureAppConfigurationOptions SelectSnapshot(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + if (!_selectCalled) + { + _selectors.Remove(DefaultQuery); + + _selectCalled = true; + } + + _selectors.AppendUnique(new KeyValueSelector + { + SnapshotName = name + }); + + return this; + } + + /// + /// 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 as a collection. + /// + /// A callback used to configure feature flag options. + public AzureAppConfigurationOptions UseFeatureFlags(Action configure = null) + { + FeatureFlagOptions options = new FeatureFlagOptions(); + configure?.Invoke(options); + + if (options.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval) + { + throw new ArgumentOutOfRangeException(nameof(options.RefreshInterval), options.RefreshInterval.TotalMilliseconds, + string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds)); + } + + if (options.FeatureFlagSelectors.Count() != 0 && options.Label != null) + { + throw new InvalidOperationException($"Please select feature flags by either the {nameof(options.Select)} method or by setting the {nameof(options.Label)} property, not both."); + } + + if (options.FeatureFlagSelectors.Count() == 0) + { + // Select clause is not present + options.FeatureFlagSelectors.Add(new KeyValueSelector + { + KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", + LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, + IsFeatureFlagSelector = true + }); + } + + foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) + { + _selectors.AppendUnique(featureFlagSelector); + + _ffWatchers.AppendUnique(new KeyValueWatcher + { + Key = featureFlagSelector.KeyFilter, + Label = featureFlagSelector.LabelFilter, Tags = featureFlagSelector.TagFilters, - // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins - RefreshInterval = options.RefreshInterval - }); - } - - return this; - } - - /// - /// Connect the provider to the Azure App Configuration service via a connection string. - /// - /// - /// Used to authenticate with Azure App Configuration. - /// - public AzureAppConfigurationOptions Connect(string connectionString) - { - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new ArgumentNullException(nameof(connectionString)); - } - - return Connect(new List { connectionString }); - } - - /// - /// Connect the provider to an Azure App Configuration store and its replicas via a list of connection strings. - /// - /// - /// Used to authenticate with Azure App Configuration. - /// - public AzureAppConfigurationOptions Connect(IEnumerable connectionStrings) - { - if (connectionStrings == null || !connectionStrings.Any()) - { - throw new ArgumentNullException(nameof(connectionStrings)); - } - - if (connectionStrings.Distinct().Count() != connectionStrings.Count()) - { - throw new ArgumentException($"All values in '{nameof(connectionStrings)}' must be unique."); - } - - Endpoints = null; - Credential = null; - ConnectionStrings = connectionStrings; - return this; - } - - /// - /// Connect the provider to Azure App Configuration using endpoint and token credentials. - /// - /// The endpoint of the Azure App Configuration to connect to. - /// Token credentials to use to connect. - public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential credential) - { - if (endpoint == null) - { - throw new ArgumentNullException(nameof(endpoint)); - } - - if (credential == null) - { - throw new ArgumentNullException(nameof(credential)); - } - - return Connect(new List() { endpoint }, credential); - } - - /// - /// Connect the provider to an Azure App Configuration store and its replicas using a list of endpoints and a token credential. - /// - /// The list of endpoints of an Azure App Configuration store and its replicas to connect to. - /// Token credential to use to connect. - public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCredential credential) - { - if (endpoints == null || !endpoints.Any()) - { - throw new ArgumentNullException(nameof(endpoints)); - } - - if (endpoints.Distinct(new EndpointComparer()).Count() != endpoints.Count()) - { - throw new ArgumentException($"All values in '{nameof(endpoints)}' must be unique."); - } - - Credential = credential ?? throw new ArgumentNullException(nameof(credential)); - - Endpoints = endpoints; - ConnectionStrings = null; - return this; - } - - /// - /// Trims the provided prefix from the keys of all key-values retrieved from Azure App Configuration. - /// - /// The prefix to be trimmed. - public AzureAppConfigurationOptions TrimKeyPrefix(string prefix) - { - if (string.IsNullOrEmpty(prefix)) - { - throw new ArgumentNullException(nameof(prefix)); - } - - _keyPrefixes.Add(prefix); - return this; - } - - /// - /// Configure the client(s) used to communicate with Azure App Configuration. - /// - /// A callback used to configure Azure App Configuration client options. - public AzureAppConfigurationOptions ConfigureClientOptions(Action configure) - { - configure?.Invoke(ClientOptions); - return this; - } - - /// - /// Configure refresh for key-values in the configuration provider. - /// - /// A 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); - - 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 InvalidOperationException($"Cannot call both {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} and " - + $"{nameof(AzureAppConfigurationRefreshOptions.Register)}."); - } - - if (RegisterAllEnabled) - { - KvCollectionRefreshInterval = refreshOptions.RefreshInterval; - } - else - { - foreach (KeyValueWatcher item in refreshOptions.RefreshRegistrations) - { - item.RefreshInterval = refreshOptions.RefreshInterval; - _individualKvWatchers.Add(item); - } - } - - return this; - } - - /// - /// Get an instance of that can be used to trigger a refresh for the registered key-values. - /// - /// An instance of . - public IConfigurationRefresher GetRefresher() - { - return _refresher; - } - - /// - /// Configures the Azure App Configuration provider to use the provided Key Vault configuration to resolve key vault references. - /// - /// A callback used to configure Azure App Configuration key vault options. - public AzureAppConfigurationOptions ConfigureKeyVault(Action configure) - { - var keyVaultOptions = new AzureAppConfigurationKeyVaultOptions(); - configure?.Invoke(keyVaultOptions); - - if (keyVaultOptions.Credential != null && keyVaultOptions.SecretResolver != null) - { - throw new InvalidOperationException($"Cannot configure both default credentials and secret resolver for Key Vault references. Please call either {nameof(keyVaultOptions.SetCredential)} or {nameof(keyVaultOptions.SetSecretResolver)} method, not both."); - } - - _adapters.RemoveAll(a => a is AzureKeyVaultKeyValueAdapter); - _adapters.Add(new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider(keyVaultOptions))); - - IsKeyVaultRefreshConfigured = keyVaultOptions.IsKeyVaultRefreshConfigured; - IsKeyVaultConfigured = true; - return this; - } - - /// - /// Provides a way to transform settings retrieved from App Configuration before they are processed by the configuration provider. - /// - /// A callback registered by the user to transform each configuration setting. - public AzureAppConfigurationOptions Map(Func> mapper) - { - if (mapper == null) - { - throw new ArgumentNullException(nameof(mapper)); - } - - _mappers.Add(mapper); - return this; - } - - /// - /// Configure the provider behavior when loading data from Azure App Configuration on startup. - /// - /// A callback used to configure Azure App Configuration startup options. - public AzureAppConfigurationOptions ConfigureStartupOptions(Action configure) - { - configure?.Invoke(Startup); - return this; - } - - private static ConfigurationClientOptions GetDefaultClientOptions() - { - var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_10_01); - clientOptions.Retry.MaxRetries = MaxRetries; - clientOptions.Retry.MaxDelay = MaxRetryDelay; - clientOptions.Retry.Mode = RetryMode.Exponential; + // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins + RefreshInterval = options.RefreshInterval + }); + } + + return this; + } + + /// + /// Connect the provider to the Azure App Configuration service via a connection string. + /// + /// + /// Used to authenticate with Azure App Configuration. + /// + public AzureAppConfigurationOptions Connect(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + return Connect(new List { connectionString }); + } + + /// + /// Connect the provider to an Azure App Configuration store and its replicas via a list of connection strings. + /// + /// + /// Used to authenticate with Azure App Configuration. + /// + public AzureAppConfigurationOptions Connect(IEnumerable connectionStrings) + { + if (connectionStrings == null || !connectionStrings.Any()) + { + throw new ArgumentNullException(nameof(connectionStrings)); + } + + if (connectionStrings.Distinct().Count() != connectionStrings.Count()) + { + throw new ArgumentException($"All values in '{nameof(connectionStrings)}' must be unique."); + } + + Endpoints = null; + Credential = null; + ConnectionStrings = connectionStrings; + return this; + } + + /// + /// Connect the provider to Azure App Configuration using endpoint and token credentials. + /// + /// The endpoint of the Azure App Configuration to connect to. + /// Token credentials to use to connect. + public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential credential) + { + if (endpoint == null) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + if (credential == null) + { + throw new ArgumentNullException(nameof(credential)); + } + + return Connect(new List() { endpoint }, credential); + } + + /// + /// Connect the provider to an Azure App Configuration store and its replicas using a list of endpoints and a token credential. + /// + /// The list of endpoints of an Azure App Configuration store and its replicas to connect to. + /// Token credential to use to connect. + public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCredential credential) + { + if (endpoints == null || !endpoints.Any()) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + if (endpoints.Distinct(new EndpointComparer()).Count() != endpoints.Count()) + { + throw new ArgumentException($"All values in '{nameof(endpoints)}' must be unique."); + } + + Credential = credential ?? throw new ArgumentNullException(nameof(credential)); + + Endpoints = endpoints; + ConnectionStrings = null; + return this; + } + + /// + /// Trims the provided prefix from the keys of all key-values retrieved from Azure App Configuration. + /// + /// The prefix to be trimmed. + public AzureAppConfigurationOptions TrimKeyPrefix(string prefix) + { + if (string.IsNullOrEmpty(prefix)) + { + throw new ArgumentNullException(nameof(prefix)); + } + + _keyPrefixes.Add(prefix); + return this; + } + + /// + /// Configure the client(s) used to communicate with Azure App Configuration. + /// + /// A callback used to configure Azure App Configuration client options. + public AzureAppConfigurationOptions ConfigureClientOptions(Action configure) + { + configure?.Invoke(ClientOptions); + return this; + } + + /// + /// Configure refresh for key-values in the configuration provider. + /// + /// A 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); + + 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 InvalidOperationException($"Cannot call both {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} and " + + $"{nameof(AzureAppConfigurationRefreshOptions.Register)}."); + } + + if (RegisterAllEnabled) + { + KvCollectionRefreshInterval = refreshOptions.RefreshInterval; + } + else + { + foreach (KeyValueWatcher item in refreshOptions.RefreshRegistrations) + { + item.RefreshInterval = refreshOptions.RefreshInterval; + _individualKvWatchers.Add(item); + } + } + + return this; + } + + /// + /// Get an instance of that can be used to trigger a refresh for the registered key-values. + /// + /// An instance of . + public IConfigurationRefresher GetRefresher() + { + return _refresher; + } + + /// + /// Configures the Azure App Configuration provider to use the provided Key Vault configuration to resolve key vault references. + /// + /// A callback used to configure Azure App Configuration key vault options. + public AzureAppConfigurationOptions ConfigureKeyVault(Action configure) + { + var keyVaultOptions = new AzureAppConfigurationKeyVaultOptions(); + configure?.Invoke(keyVaultOptions); + + if (keyVaultOptions.Credential != null && keyVaultOptions.SecretResolver != null) + { + throw new InvalidOperationException($"Cannot configure both default credentials and secret resolver for Key Vault references. Please call either {nameof(keyVaultOptions.SetCredential)} or {nameof(keyVaultOptions.SetSecretResolver)} method, not both."); + } + + _adapters.RemoveAll(a => a is AzureKeyVaultKeyValueAdapter); + _adapters.Add(new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider(keyVaultOptions))); + + IsKeyVaultRefreshConfigured = keyVaultOptions.IsKeyVaultRefreshConfigured; + IsKeyVaultConfigured = true; + return this; + } + + /// + /// Provides a way to transform settings retrieved from App Configuration before they are processed by the configuration provider. + /// + /// A callback registered by the user to transform each configuration setting. + public AzureAppConfigurationOptions Map(Func> mapper) + { + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + _mappers.Add(mapper); + return this; + } + + /// + /// Configure the provider behavior when loading data from Azure App Configuration on startup. + /// + /// A callback used to configure Azure App Configuration startup options. + public AzureAppConfigurationOptions ConfigureStartupOptions(Action configure) + { + configure?.Invoke(Startup); + return this; + } + + private static ConfigurationClientOptions GetDefaultClientOptions() + { + var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_10_01); + clientOptions.Retry.MaxRetries = MaxRetries; + clientOptions.Retry.MaxDelay = MaxRetryDelay; + clientOptions.Retry.Mode = RetryMode.Exponential; clientOptions.Retry.NetworkTimeout = NetworkTimeout; - clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); - - return clientOptions; - } - } -} + clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); + + return clientOptions; + } + } +} From 138a760cb028b90fb80ead825e07bea58796897e Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Tue, 13 May 2025 09:56:53 -0700 Subject: [PATCH 14/37] update package versions to 8.2.0 (#662) --- .../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 b9f6bfc3..21d5bfc0 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.2 + 8.2.0 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 6236ba4f..64b28f0f 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.2 + 8.2.0 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 b83cf2e2..87c4251d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -37,7 +37,7 @@ - 8.1.2 + 8.2.0 From f2cbacaa10ccb20c37dd2ec7b911c4b4c20bcdfe Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Tue, 13 May 2025 13:58:58 -0700 Subject: [PATCH 15/37] Add .gitattributes and normalize line endings (#661) * add gitattributes and renormalize any files * newline end of file * file start with mit license * fix typo --- .gitattributes | 3 + NOTICE | 452 +++++++++--------- .../AzureAppConfigurationClientFactory.cs | 2 +- .../Extensions/ListExtensions.cs | 86 ++-- .../JsonElementExtensions.cs | 5 +- .../LogHelper.cs | 18 +- 6 files changed, 286 insertions(+), 280 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..538c95f5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# If there are abnormal line endings in any file, run "git add --renormalize ", +# review the changes, and commit them to fix the line endings. +* text=auto diff --git a/NOTICE b/NOTICE index 2cec0531..8b411a0d 100644 --- a/NOTICE +++ b/NOTICE @@ -1,226 +1,226 @@ -NOTICES AND INFORMATION -Do Not Translate or Localize - -This software incorporates material from third parties. Microsoft makes certain -open source code available at https://3rdpartysource.microsoft.com, or you may -send a check or money order for US $5.00, including the product name, the open -source component name, and version number, to: - -Source Code Compliance Team -Microsoft Corporation -One Microsoft Way -Redmond, WA 98052 -USA - -Notwithstanding any other terms, you may reverse engineer this software to the -extent required to debug changes to any libraries licensed under the GNU Lesser -General Public License. - ---- - -## [DnsClient.NET](https://github.com/MichaCo/DnsClient.NET) - -Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +NOTICES AND INFORMATION +Do Not Translate or Localize + +This software incorporates material from third parties. Microsoft makes certain +open source code available at https://3rdpartysource.microsoft.com, or you may +send a check or money order for US $5.00, including the product name, the open +source component name, and version number, to: + +Source Code Compliance Team +Microsoft Corporation +One Microsoft Way +Redmond, WA 98052 +USA + +Notwithstanding any other terms, you may reverse engineer this software to the +extent required to debug changes to any libraries licensed under the GNU Lesser +General Public License. + +--- + +## [DnsClient.NET](https://github.com/MichaCo/DnsClient.NET) + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs index 6127822d..caac6ab9 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationClientFactory.cs @@ -59,7 +59,7 @@ public ConfigurationClient CreateClient(string endpoint) string connectionString = _connectionStrings.FirstOrDefault(cs => ConnectionStringUtils.Parse(cs, ConnectionStringUtils.EndpointSection) == endpoint); // - // falback to the first connection string + // fallback to the first connection string if (connectionString == null) { string id = ConnectionStringUtils.Parse(_connectionStrings.First(), ConnectionStringUtils.IdSection); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs index 93722539..3726a174 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // -using DnsClient.Protocol; -using System; +using DnsClient.Protocol; +using System; using System.Collections.Generic; using System.Linq; @@ -10,47 +10,47 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class ListExtensions { - public static List Shuffle(this List values) - { - var rdm = new Random(); - int count = values.Count; - - for (int i = count - 1; i > 0; i--) - { - int swapIndex = rdm.Next(i + 1); - - if (swapIndex != i) - { - T value = values[swapIndex]; - values[swapIndex] = values[i]; - values[i] = value; - } - } - - return values; - } - - public static List SortSrvRecords(this List srvRecords) - { - srvRecords.Sort((a, b) => - { - if (a.Priority != b.Priority) - return a.Priority.CompareTo(b.Priority); - - if (a.Weight != b.Weight) - return b.Weight.CompareTo(a.Weight); - - return 0; - }); - - return srvRecords; - } - + public static List Shuffle(this List values) + { + var rdm = new Random(); + int count = values.Count; + + for (int i = count - 1; i > 0; i--) + { + int swapIndex = rdm.Next(i + 1); + + if (swapIndex != i) + { + T value = values[swapIndex]; + values[swapIndex] = values[i]; + values[i] = value; + } + } + + return values; + } + + public static List SortSrvRecords(this List srvRecords) + { + srvRecords.Sort((a, b) => + { + if (a.Priority != b.Priority) + return a.Priority.CompareTo(b.Priority); + + if (a.Weight != b.Weight) + return b.Weight.CompareTo(a.Weight); + + return 0; + }); + + return srvRecords; + } + public static void AppendUnique(this List items, T item) { - if (item == null) - { - throw new ArgumentNullException(nameof(item)); + if (item == null) + { + throw new ArgumentNullException(nameof(item)); } T existingItem = items.FirstOrDefault(s => Equals(s, item)); @@ -63,6 +63,6 @@ public static void AppendUnique(this List items, T item) // Append to the end, keeping precedence. items.Add(item); - } + } } -} +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs index fc7f8b26..e987b5ca 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/JsonElementExtensions.cs @@ -1,4 +1,7 @@ -using System; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; using System.IO; using System.Linq; using System.Text; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs index 4f999406..bebb5aa4 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs @@ -26,13 +26,13 @@ public static string BuildFeatureFlagsUnchangedMessage(string endpoint) } public static string BuildFeatureFlagsUpdatedMessage() - { + { return LoggingConstants.RefreshFeatureFlagsUpdated; } - public static string BuildSelectedKeyValueCollectionsUnchangedMessage(string endpoint) - { - return $"{LoggingConstants.RefreshSelectedKeyValueCollectionsUnchanged} Endpoint:'{endpoint?.TrimEnd('/')}'"; + public static string BuildSelectedKeyValueCollectionsUnchangedMessage(string endpoint) + { + return $"{LoggingConstants.RefreshSelectedKeyValueCollectionsUnchanged} Endpoint:'{endpoint?.TrimEnd('/')}'"; } public static string BuildSelectedKeyValuesAndFeatureFlagsUpdatedMessage() @@ -91,12 +91,12 @@ public static string BuildLastEndpointFailedMessage(string endpoint) } public static string BuildFallbackClientLookupFailMessage(string exceptionMessage) - { + { return $"{LoggingConstants.FallbackClientLookupError}\n{exceptionMessage}"; - } - public static string BuildRefreshFailedDueToFormattingErrorMessage(string exceptionMessage) - { - return $"{LoggingConstants.RefreshFailedDueToFormattingError}\n{exceptionMessage}"; + } + public static string BuildRefreshFailedDueToFormattingErrorMessage(string exceptionMessage) + { + return $"{LoggingConstants.RefreshFailedDueToFormattingError}\n{exceptionMessage}"; } } } From 8b883cce448004c901bb9ef3a855ee6576bd2281 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Tue, 13 May 2025 14:56:00 -0700 Subject: [PATCH 16/37] update to 2023-11-01 (#663) --- .../AzureAppConfigurationOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 1e79daba..bb48372a 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -546,7 +546,7 @@ public AzureAppConfigurationOptions ConfigureStartupOptions(Action Date: Tue, 27 May 2025 12:17:38 -0700 Subject: [PATCH 17/37] Add integration testing (#634) * add initial integration test file * update auth * add skipping * organize tests into unit and integration * update formatting * WIP create config store * edit * update integration setup * copilot updates * initial working version of integration tests * remove obsolete tags and update formatting * updating tests * format * remove skipping, add tests * add cleanup stale resource call on startup and dispose * add back removed line * use persistent resource group * add ordering of apis test * add refresh test * add tests * add failover test * adding snapshot tests first draft * fix snapshot tests * add key vault tests * add more keyvault tests * fix keyvault tests * add cleanup of key values/secrets/snapshot * fix key vault isolation * remove base exception catches * update ci.yml to work with integration tests and use github repo secrets * add id token write permission * update ci.yml step to create subscription file * edit ci.yml * fix github action * update comment in powershell script * remove unused packages * update comment, remove unused methods * add request tracing test * check for status code in catch, move cleanupstaleresources call to dispose * PR comments * comments * comments * update copilot instructions * update copilot instructions * fix try blocks to only surround throwing code * fix setuptestkeys, simplify comments and setup code * tagfilters integration test in progress * tagfilters integration test in progress * correct api version * fixed tagfilter test * fix requesttracing test * allow commas in json reader * stale threshold * remove copilot instructions * use outputhelper * fix cleanupstale * update setup method, remove flag --- .github/workflows/ci.yml | 10 + .gitignore | 3 + .../FeatureManagementKeyValueAdapter.cs | 7 +- .../Integration/GetAzureSubscription.ps1 | 47 + .../Integration/IntegrationTests.cs | 2086 +++++++++++++++++ .../Tests.AzureAppConfiguration.csproj | 15 +- .../{ => Unit}/CallbackMessageHandler.cs | 0 .../{ => Unit}/ClientOptionsTests.cs | 0 .../{ => Unit}/ConnectTests.cs | 0 .../{ => Unit}/FailoverTests.cs | 0 .../{ => Unit}/FeatureManagementTests.cs | 0 .../HttpRequestCountPipelinePolicy.cs | 0 .../{ => Unit}/JsonContentTypeTests.cs | 0 .../{ => Unit}/KeyVaultReferenceTests.cs | 0 .../{ => Unit}/LoadBalancingTests.cs | 0 .../{ => Unit}/LoggingTests.cs | 0 .../{ => Unit}/MapTests.cs | 0 .../MockedConfigurationClientManager.cs | 0 .../{ => Unit}/PushRefreshTests.cs | 0 .../{ => Unit}/RefreshTests.cs | 0 .../{ => Unit}/TagFiltersTests.cs | 0 .../{ => Unit}/TestHelper.cs | 0 .../{ => Unit}/Tests.cs | 0 23 files changed, 2165 insertions(+), 3 deletions(-) create mode 100644 tests/Tests.AzureAppConfiguration/Integration/GetAzureSubscription.ps1 create mode 100644 tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs rename tests/Tests.AzureAppConfiguration/{ => Unit}/CallbackMessageHandler.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/ClientOptionsTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/ConnectTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/FailoverTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/FeatureManagementTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/HttpRequestCountPipelinePolicy.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/JsonContentTypeTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/KeyVaultReferenceTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/LoadBalancingTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/LoggingTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/MapTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/MockedConfigurationClientManager.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/PushRefreshTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/RefreshTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/TagFiltersTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/TestHelper.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/Tests.cs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af9b1d22..2928be53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ on: permissions: security-events: write + id-token: write jobs: build: @@ -40,8 +41,17 @@ jobs: - name: Dotnet Pack run: pwsh pack.ps1 + - name: Azure Login with OIDC + uses: azure/login@v1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Dotnet Test run: pwsh test.ps1 + env: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Publish Test Results uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 530af064..39f97d76 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # Azure Functions localsettings file local.settings.json +# Integration test secrets +appsettings.Secrets.json + # User-specific files *.suo *.user diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index cbfa411a..13408385 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -426,7 +426,12 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) { var featureFlag = new FeatureFlag(); - var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(settingValue)); + var reader = new Utf8JsonReader( + System.Text.Encoding.UTF8.GetBytes(settingValue), + new JsonReaderOptions + { + AllowTrailingCommas = true + }); try { diff --git a/tests/Tests.AzureAppConfiguration/Integration/GetAzureSubscription.ps1 b/tests/Tests.AzureAppConfiguration/Integration/GetAzureSubscription.ps1 new file mode 100644 index 00000000..c08e434d --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/Integration/GetAzureSubscription.ps1 @@ -0,0 +1,47 @@ +#!/usr/bin/env pwsh + +# GetAzureSubscription.ps1 +# This script gets the AppConfig - Dev subscription ID and saves it to a JSON file + +$outputPath = Join-Path $PSScriptRoot "appsettings.Secrets.json" + +Write-Host "Checking for active Azure CLI login" + +az account show | Out-Null + +if ($LASTEXITCODE -ne 0) { + Write-Host "Must be logged in with the Azure CLI to proceed" + + az login + + if ($LASTEXITCODE -ne 0) { + Write-Host "Azure login failed" + return + } +} + +az account set --name "AppConfig - Dev" + +# Get current subscription from az CLI +$subscriptionId = az account show --query id -o tsv 2>&1 + +if ($LASTEXITCODE -ne 0) { + Write-Host "Azure CLI command failed with exit code $LASTEXITCODE. Output: $subscriptionId" + return +} + +# Check if the output is empty +if ([string]::IsNullOrWhiteSpace($subscriptionId)) { + Write-Host "No active Azure subscription found. Please run 'az login' first." + + exit 1 +} + +# If successful, save the subscription ID to a JSON file +$result = @{ + SubscriptionId = $subscriptionId.Trim() +} + +$result | ConvertTo-Json | Out-File $outputPath -Encoding utf8 +Write-Host "Subscription information saved to: $outputPath" +exit 0 diff --git a/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs b/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs new file mode 100644 index 00000000..b932b327 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs @@ -0,0 +1,2086 @@ +using Azure; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Data.AppConfiguration; +using Azure.Identity; +using Azure.ResourceManager; +using Azure.ResourceManager.AppConfiguration; +using Azure.ResourceManager.AppConfiguration.Models; +using Azure.ResourceManager.KeyVault; +using Azure.ResourceManager.Resources; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using Microsoft.FeatureManagement; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Tests.AzureAppConfiguration +{ + using Xunit.Abstractions; + + /// + /// Integration tests for Azure App Configuration that connect to a real service. + /// Uses an existing App Configuration store and Key Vault for testing. + /// Requires Azure credentials with appropriate permissions. + /// NOTE: Before running these tests locally, execute the GetAzureSubscription.ps1 script to create appsettings.Secrets.json. + /// + [Trait("Category", "Integration")] + [CollectionDefinition(nameof(IntegrationTests), DisableParallelization = true)] + public class IntegrationTests : IAsyncLifetime + { + // Test constants + private const string TestKeyPrefix = "IntegrationTest"; + private const string SubscriptionJsonPath = "appsettings.Secrets.json"; + private static readonly TimeSpan StaleResourceThreshold = TimeSpan.FromHours(3); + private const string KeyVaultReferenceLabel = "KeyVaultRef"; + + // Content type constants + private const string FeatureFlagContentType = FeatureManagementConstants.ContentType + ";charset=utf-8"; + private const string JsonContentType = "application/json"; + + // Fixed resource names - already existing + private const string AppConfigStoreName = "appconfig-dotnetprovider-integrationtest"; + private const string KeyVaultName = "keyvault-dotnetprovider"; + private const string ResourceGroupName = "dotnetprovider-integrationtest"; + + private readonly DefaultAzureCredential _defaultAzureCredential = new DefaultAzureCredential( + new DefaultAzureCredentialOptions + { + ExcludeSharedTokenCacheCredential = true + }); + + private class TestContext + { + public string KeyPrefix { get; set; } + public string SentinelKey { get; set; } + public string FeatureFlagKey { get; set; } + public string KeyVaultReferenceKey { get; set; } + public string SecretValue { get; set; } + } + + private ConfigurationClient _configClient; + + private SecretClient _secretClient; + + private string _connectionString; + + private Uri _keyVaultEndpoint; + + private readonly ITestOutputHelper _output; + + public IntegrationTests(ITestOutputHelper output) + { + _output = output; + } + + private string GetCurrentSubscriptionId() + { + string subscriptionIdFromEnv = Environment.GetEnvironmentVariable("AZURE_SUBSCRIPTION_ID"); + + if (!string.IsNullOrEmpty(subscriptionIdFromEnv)) + { + return subscriptionIdFromEnv; + } + + // Read the JSON file created by the script + string jsonPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Integration", SubscriptionJsonPath); + + if (!File.Exists(jsonPath)) + { + throw new InvalidOperationException($"Subscription JSON file not found at {jsonPath}. Run the GetAzureSubscription.ps1 script first."); + } + + string jsonContent = File.ReadAllText(jsonPath); + + using JsonDocument doc = JsonDocument.Parse(jsonContent); + JsonElement root = doc.RootElement; + + return root.GetProperty("SubscriptionId").GetString(); + } + + private string GetUniqueKeyPrefix(string testName) + { + // Use a combination of the test prefix and test method name to ensure uniqueness + return $"{TestKeyPrefix}-{testName}-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + } + + private async Task CreateSnapshot(string snapshotName, IEnumerable settingsToInclude, CancellationToken cancellationToken = default) + { + ConfigurationSnapshot snapshot = new ConfigurationSnapshot(settingsToInclude); + + snapshot.SnapshotComposition = SnapshotComposition.Key; + + CreateSnapshotOperation operation = await _configClient.CreateSnapshotAsync( + WaitUntil.Completed, + snapshotName, + snapshot, + cancellationToken); + + return operation.Value.Name; + } + + public async Task InitializeAsync() + { + DefaultAzureCredential credential = _defaultAzureCredential; + string subscriptionId = GetCurrentSubscriptionId(); + + var armClient = new ArmClient(credential); + SubscriptionResource subscription = armClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{subscriptionId}")); + + ResourceGroupResource resourceGroup = await subscription.GetResourceGroups().GetAsync(ResourceGroupName); + + AppConfigurationStoreResource appConfigStore = null; + + try + { + appConfigStore = await resourceGroup.GetAppConfigurationStores().GetAsync(AppConfigStoreName); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + throw new InvalidOperationException($"App Configuration store '{AppConfigStoreName}' not found in resource group '{ResourceGroupName}'. Please create it before running tests.", ex); + } + + AsyncPageable accessKeys = appConfigStore.GetKeysAsync(); + + _connectionString = (await accessKeys.FirstAsync()).ConnectionString; + + _configClient = new ConfigurationClient(_connectionString); + + KeyVaultResource keyVault = null; + + // Find and initialize Key Vault - look in the same resource group + keyVault = await resourceGroup.GetKeyVaults().GetAsync(KeyVaultName); + + if (keyVault == null) + { + throw new InvalidOperationException( + $"Key Vault '{KeyVaultName}' not found in subscription {subscriptionId}. " + + "This resource is required for integration tests. " + + "Please create the Key Vault with the appropriate permissions before running tests."); + } + + _keyVaultEndpoint = keyVault.Data.Properties.VaultUri; + + _secretClient = new SecretClient(_keyVaultEndpoint, credential); + + _output.WriteLine($"Successfully connected to App Configuration store '{AppConfigStoreName}' and Key Vault '{KeyVaultName}'"); + } + + private async Task CleanupStaleResources() + { + _output.WriteLine($"Checking for stale resources older than {StaleResourceThreshold}..."); + + var cutoffTime = DateTimeOffset.UtcNow.Subtract(StaleResourceThreshold); + var cleanupTasks = new List(); + + // Clean up stale configuration settings, snapshots, and Key Vault secrets + try + { + int staleConfigCount = 0; + var configSettingsToCleanup = new List(); + + AsyncPageable kvSettings = _configClient.GetConfigurationSettingsAsync(new SettingSelector + { + KeyFilter = TestKeyPrefix + "*" + }); + + AsyncPageable flagSettings = _configClient.GetConfigurationSettingsAsync(new SettingSelector + { + KeyFilter = FeatureManagementConstants.FeatureFlagMarker + TestKeyPrefix + "*" + }); + + await foreach (ConfigurationSetting setting in kvSettings.Concat(flagSettings)) + { + // Check if the setting is older than the threshold + if (setting.LastModified < cutoffTime) + { + configSettingsToCleanup.Add(setting); + staleConfigCount++; + } + } + + foreach (ConfigurationSetting setting in configSettingsToCleanup) + { + cleanupTasks.Add(_configClient.DeleteConfigurationSettingAsync(setting.Key, setting.Label)); + } + + int staleSnapshotCount = 0; + AsyncPageable snapshots = _configClient.GetSnapshotsAsync(new SnapshotSelector()); + await foreach (ConfigurationSnapshot snapshot in snapshots) + { + if (snapshot.Name.StartsWith("snapshot-" + TestKeyPrefix) && snapshot.CreatedOn < cutoffTime) + { + cleanupTasks.Add(_configClient.ArchiveSnapshotAsync(snapshot.Name)); + staleSnapshotCount++; + } + } + + int staleSecretCount = 0; + if (_secretClient != null) + { + AsyncPageable secrets = _secretClient.GetPropertiesOfSecretsAsync(); + await foreach (SecretProperties secretProperties in secrets) + { + if (secretProperties.Name.StartsWith(TestKeyPrefix) && secretProperties.CreatedOn.HasValue && secretProperties.CreatedOn.Value < cutoffTime) + { + cleanupTasks.Add(_secretClient.StartDeleteSecretAsync(secretProperties.Name)); + staleSecretCount++; + } + } + } + + // Wait for all cleanup tasks to complete + await Task.WhenAll(cleanupTasks); + + _output.WriteLine($"Cleaned up {staleConfigCount} stale configuration settings, {staleSnapshotCount} snapshots, and {staleSecretCount} secrets"); + } + catch (RequestFailedException ex) + { + _output.WriteLine($"Error during stale resource cleanup: {ex.Message}"); + // Continue execution even if cleanup fails + } + } + + public async Task DisposeAsync() + { + await CleanupStaleResources(); + } + + private TestContext CreateTestContext(string testName) + { + string keyPrefix = GetUniqueKeyPrefix(testName); + string sentinelKey = $"{keyPrefix}:Sentinel"; + string featureFlagKey = $".appconfig.featureflag/{keyPrefix}Feature"; + string secretName = $"{keyPrefix}-secret"; + string secretValue = "SecretValue"; + string keyVaultReferenceKey = $"{keyPrefix}:KeyVaultRef"; + + return new TestContext + { + KeyPrefix = keyPrefix, + SentinelKey = sentinelKey, + FeatureFlagKey = featureFlagKey, + KeyVaultReferenceKey = keyVaultReferenceKey, + SecretValue = secretValue + }; + } + + private async Task SetupKeyValues(TestContext context) + { + var testSettings = new List + { + new ConfigurationSetting($"{context.KeyPrefix}:Setting1", "InitialValue1"), + new ConfigurationSetting($"{context.KeyPrefix}:Setting2", "InitialValue2"), + new ConfigurationSetting(context.SentinelKey, "Initial") + }; + + foreach (ConfigurationSetting setting in testSettings) + { + await _configClient.SetConfigurationSettingAsync(setting); + } + } + + private async Task SetupFeatureFlags(TestContext context) + { + var featureFlagSetting = ConfigurationModelFactory.ConfigurationSetting( + context.FeatureFlagKey, + @"{""id"":""" + context.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":false}", + contentType: FeatureFlagContentType); + + await _configClient.SetConfigurationSettingAsync(featureFlagSetting); + } + + private async Task SetupKeyVaultReferences(TestContext context) + { + if (_secretClient != null) + { + await _secretClient.SetSecretAsync(context.KeyPrefix + "-secret", context.SecretValue); + + string keyVaultUri = $"{_keyVaultEndpoint}secrets/{context.KeyPrefix}-secret"; + string keyVaultRefValue = @$"{{""uri"":""{keyVaultUri}""}}"; + + ConfigurationSetting keyVaultRefSetting = ConfigurationModelFactory.ConfigurationSetting( + context.KeyVaultReferenceKey, + keyVaultRefValue, + label: KeyVaultReferenceLabel, + contentType: KeyVaultConstants.ContentType); + + await _configClient.SetConfigurationSettingAsync(keyVaultRefSetting); + } + } + + private async Task SetupTaggedSettings(TestContext context) + { + // Create configuration settings with various tags + var taggedSettings = new List + { + // Basic environment tags + CreateSettingWithTags( + $"{context.KeyPrefix}:TaggedSetting1", + "Value1", + new Dictionary { + { "Environment", "Development" }, + { "App", "TestApp" } + }), + + CreateSettingWithTags( + $"{context.KeyPrefix}:TaggedSetting2", + "Value2", + new Dictionary { + { "Environment", "Production" }, + { "App", "TestApp" } + }), + + CreateSettingWithTags( + $"{context.KeyPrefix}:TaggedSetting3", + "Value3", + new Dictionary { + { "Environment", "Development" }, + { "Component", "API" } + }), + + // Special characters in tags + CreateSettingWithTags( + $"{context.KeyPrefix}:TaggedSetting4", + "Value4", + new Dictionary { + { "Special:Tag", "Value:With:Colons" }, + { "Tag@With@At", "Value@With@At" } + }), + + // Empty and null tag values + CreateSettingWithTags( + $"{context.KeyPrefix}:TaggedSetting5", + "Value5", + new Dictionary { + { "EmptyTag", "" }, + { "NullTag", null } + }) + }; + + foreach (ConfigurationSetting setting in taggedSettings) + { + await _configClient.SetConfigurationSettingAsync(setting); + } + + // Create feature flags with tags + var taggedFeatureFlags = new List + { + // Basic environment tags on feature flags + CreateFeatureFlagWithTags( + $"{context.KeyPrefix}:FeatureDev", + true, + new Dictionary { + { "Environment", "Development" }, + { "App", "TestApp" } + }), + + CreateFeatureFlagWithTags( + $"{context.KeyPrefix}:FeatureProd", + false, + new Dictionary { + { "Environment", "Production" }, + { "App", "TestApp" } + }), + + // Feature flags with special character tags + CreateFeatureFlagWithTags( + $"{context.KeyPrefix}:FeatureSpecial", + true, + new Dictionary { + { "Special:Tag", "Value:With:Colons" } + }), + + // Feature flags with empty/null tags + CreateFeatureFlagWithTags( + $"{context.KeyPrefix}:FeatureEmpty", + false, + new Dictionary { + { "EmptyTag", "" }, + { "NullTag", null } + }) + }; + + foreach (ConfigurationSetting setting in taggedFeatureFlags) + { + await _configClient.SetConfigurationSettingAsync(setting); + } + } + + private async Task SetupAllTestData(string testName) + { + TestContext context = CreateTestContext(testName); + + await SetupKeyValues(context); + await SetupFeatureFlags(context); + await SetupKeyVaultReferences(context); + + return context; + } + + [Fact] + public async Task LoadConfiguration_RetrievesValuesFromAppConfiguration() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("BasicConfig"); + await SetupKeyValues(testContext); + + // Act + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + }) + .Build(); + + // Assert + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + } + + [Fact] + public async Task RefreshAsync_UpdatesConfiguration_WhenSentinelKeyChanged() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("UpdatesConfig"); + await SetupKeyValues(testContext); + IConfigurationRefresher refresher = null; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, refreshAll: true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + + // Update values in the store + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "Updated")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Act + await refresher.RefreshAsync(); + + // Assert + Assert.Equal("UpdatedValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + } + + [Fact] + public async Task RegisterAll_RefreshesAllKeys() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("RefreshesAllKeys"); + await SetupKeyValues(testContext); + IConfigurationRefresher refresher = null; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + + // Use RegisterAll to refresh everything when sentinel changes + options.ConfigureRefresh(refresh => + { + refresh.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + + // Update all values in the store + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting2", "UpdatedValue2")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Act + await refresher.RefreshAsync(); + + // Assert + Assert.Equal("UpdatedValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("UpdatedValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + } + + [Fact] + public async Task RefreshAsync_SentinelKeyUnchanged() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("SentinelUnchanged"); + await SetupKeyValues(testContext); + IConfigurationRefresher refresher = null; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + + // Update data but not sentinel + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Act + await refresher.RefreshAsync(); + + // Assert + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); // Should not update + } + + [Fact] + public async Task RefreshAsync_RefreshesFeatureFlags_WhenConfigured() + { + TestContext testContext = CreateTestContext("FeatureFlagRefresh"); + await SetupFeatureFlags(testContext); + IConfigurationRefresher refresher = null; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + + // Configure feature flags with the correct ID pattern + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + featureFlagOptions.SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify the feature flag is disabled initially + Assert.Equal("False", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + + // Update the feature flag to enabled=true + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Act + await refresher.RefreshAsync(); + + // Assert + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + } + + [Fact] + public async Task UseFeatureFlags_WithClientFiltersAndConditions() + { + TestContext testContext = CreateTestContext("FeatureFlagFilters"); + await SetupFeatureFlags(testContext); + + // Create a feature flag with complex conditions + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{ + ""id"": """ + testContext.KeyPrefix + @"Feature"", + ""description"": ""Test feature with filters"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [""Chrome"", ""Edge""] + } + }, + { + ""name"": ""TimeWindow"", + ""parameters"": { + ""Start"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds() + @")\/"", + ""End"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds() + @")\/"" + } + } + ] + } + }", + contentType: FeatureFlagContentType)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + }) + .Build(); + + // Verify feature flag structure is loaded correctly + Assert.Equal("Browser", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:0:Name"]); + Assert.Equal("Chrome", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Edge", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + Assert.Equal("TimeWindow", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:1:Name"]); + } + + [Fact] + public async Task MultipleProviders_LoadAndRefresh() + { + TestContext testContext1 = CreateTestContext("MultiProviderTest1"); + await SetupKeyValues(testContext1); + TestContext testContext2 = CreateTestContext("MultiProviderTest2"); + await SetupKeyValues(testContext2); + IConfigurationRefresher refresher1 = null; + IConfigurationRefresher refresher2 = null; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext1.KeyPrefix}:*"); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext1.SentinelKey, true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher1 = options.GetRefresher(); + }) + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext2.KeyPrefix}:*"); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext2.SentinelKey) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher2 = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal("InitialValue1", config[$"{testContext1.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue1", config[$"{testContext2.KeyPrefix}:Setting1"]); + + // Update values and sentinel keys + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext1.KeyPrefix}:Setting1", "UpdatedValue1")); + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext1.SentinelKey, "Updated")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Refresh only the first provider + await refresher1.RefreshAsync(); + + // Assert: Only the first provider's values should be updated + Assert.Equal("UpdatedValue1", config[$"{testContext1.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue1", config[$"{testContext2.KeyPrefix}:Setting1"]); + } + + [Fact] + public async Task FeatureFlag_WithVariants() + { + TestContext testContext = CreateTestContext("FeatureFlagVariants"); + await SetupFeatureFlags(testContext); + + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + // Create a feature flag with variants + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey + "WithVariants", + @"{ + ""id"": """ + testContext.KeyPrefix + @"FeatureWithVariants"", + ""description"": ""Feature flag with variants"", + ""enabled"": true, + ""conditions"": { ""client_filters"": [] }, + ""variants"": [ + { + ""name"": ""LargeSize"", + ""configuration_value"": ""800px"" + }, + { + ""name"": ""MediumSize"", + ""configuration_value"": ""600px"" + }, + { + ""name"": ""SmallSize"", + ""configuration_value"": ""400px"" + } + ], + ""allocation"": { + ""default_when_enabled"": ""MediumSize"" + } + }", + contentType: FeatureFlagContentType)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + }) + .Build(); + + // Verify variants are loaded correctly + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("LargeSize", config[$"feature_management:feature_flags:0:variants:0:name"]); + Assert.Equal("800px", config[$"feature_management:feature_flags:0:variants:0:configuration_value"]); + Assert.Equal("MediumSize", config[$"feature_management:feature_flags:0:variants:1:name"]); + Assert.Equal("600px", config[$"feature_management:feature_flags:0:variants:1:configuration_value"]); + Assert.Equal("SmallSize", config[$"feature_management:feature_flags:0:variants:2:name"]); + Assert.Equal("400px", config[$"feature_management:feature_flags:0:variants:2:configuration_value"]); + Assert.Equal("MediumSize", config[$"feature_management:feature_flags:0:allocation:default_when_enabled"]); + } + + [Fact] + public async Task JsonContentType_LoadsAndFlattensHierarchicalData() + { + TestContext testContext = CreateTestContext("JsonContent"); + await SetupKeyValues(testContext); + + // Create a complex JSON structure + string jsonKey = $"{testContext.KeyPrefix}:JsonConfig"; + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + jsonKey, + @"{ + ""database"": { + ""connection"": { + ""string"": ""Server=myserver;Database=mydb;User Id=sa;Password=mypassword;"", + ""timeout"": 30 + }, + ""retries"": 3, + ""enabled"": true + }, + ""logging"": { + ""level"": ""Information"", + ""providers"": [""Console"", ""Debug"", ""EventLog""] + } + }", + contentType: JsonContentType)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + }) + .Build(); + + // Verify JSON was flattened properly + Assert.Equal("Server=myserver;Database=mydb;User Id=sa;Password=mypassword;", config[$"{jsonKey}:database:connection:string"]); + Assert.Equal("30", config[$"{jsonKey}:database:connection:timeout"]); + Assert.Equal("3", config[$"{jsonKey}:database:retries"]); + Assert.Equal("True", config[$"{jsonKey}:database:enabled"]); + Assert.Equal("Information", config[$"{jsonKey}:logging:level"]); + Assert.Equal("Console", config[$"{jsonKey}:logging:providers:0"]); + Assert.Equal("Debug", config[$"{jsonKey}:logging:providers:1"]); + Assert.Equal("EventLog", config[$"{jsonKey}:logging:providers:2"]); + } + + [Fact] + public async Task MethodOrderingDoesNotAffectConfiguration() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("MethodOrdering"); + await SetupKeyValues(testContext); + await SetupFeatureFlags(testContext); + + // Add an additional feature flag for testing + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey + "_Ordering", + @"{ + ""id"": """ + testContext.KeyPrefix + @"FeatureOrdering"", + ""description"": ""Test feature for ordering"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [] + } + }", + contentType: FeatureFlagContentType)); + + // Add a section-based setting + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{testContext.KeyPrefix}:Section1:Setting1", "SectionValue1")); + + // Create four different configurations with different method orderings + var configurations = new List(); + IConfigurationRefresher refresher1 = null; + IConfigurationRefresher refresher2 = null; + IConfigurationRefresher refresher3 = null; + IConfigurationRefresher refresher4 = null; + + // Configuration 1: Select -> ConfigureRefresh -> UseFeatureFlags + var config1 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + + refresher1 = options.GetRefresher(); + }) + .Build(); + configurations.Add(config1); + + // Configuration 2: ConfigureRefresh -> Select -> UseFeatureFlags + var config2 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + options.Select($"{testContext.KeyPrefix}:*"); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + + refresher2 = options.GetRefresher(); + }) + .Build(); + configurations.Add(config2); + + // Configuration 3: UseFeatureFlags -> Select -> ConfigureRefresh + var config3 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + options.Select($"{testContext.KeyPrefix}:*"); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher3 = options.GetRefresher(); + }) + .Build(); + configurations.Add(config3); + + // Configuration 4: UseFeatureFlags (with Select inside) -> ConfigureRefresh -> Select + var config4 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + options.Select($"{testContext.KeyPrefix}:*"); + + refresher4 = options.GetRefresher(); + }) + .Build(); + configurations.Add(config4); + + // Assert - Initial values should be the same across all configurations + foreach (var config in configurations) + { + // Regular settings + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("SectionValue1", config[$"{testContext.KeyPrefix}:Section1:Setting1"]); + + // Feature flags + Assert.Equal("False", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}FeatureOrdering"]); + } + + // Update values in the store + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{testContext.KeyPrefix}:Section1:Setting1", "UpdatedSectionValue1")); + + // Update a feature flag + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{ + ""id"": """ + testContext.KeyPrefix + @"Feature"", + ""description"": ""Updated test feature"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [] + } + }", + contentType: FeatureFlagContentType)); + + // Update the sentinel key to trigger refresh + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting(testContext.SentinelKey, "Updated")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Refresh all configurations + await refresher1.RefreshAsync(); + await refresher2.RefreshAsync(); + await refresher3.RefreshAsync(); + await refresher4.RefreshAsync(); + + // Assert - Updated values should be the same across all configurations + foreach (var config in configurations) + { + // Regular settings + Assert.Equal("UpdatedValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("UpdatedSectionValue1", config[$"{testContext.KeyPrefix}:Section1:Setting1"]); + + // Feature flags - first one should be updated to true + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}FeatureOrdering"]); + } + } + + [Fact] + public async Task RegisterWithRefreshAllAndRegisterAll_BehaveIdentically() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("RefreshEquivalency"); + await SetupKeyValues(testContext); + await SetupFeatureFlags(testContext); + + // Add another feature flag for testing + string secondFeatureFlagKey = $".appconfig.featureflag/{testContext.KeyPrefix}Feature2"; + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + secondFeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature2"",""description"":""Second test feature"",""enabled"":false}", + contentType: FeatureFlagContentType)); + + // Create two separate configuration builders with different refresh methods + // First configuration uses Register with refreshAll: true + IConfigurationRefresher refresher1 = null; + var config1 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, refreshAll: true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher1 = options.GetRefresher(); + }) + .Build(); + + // Second configuration uses RegisterAll() + IConfigurationRefresher refresher2 = null; + var config2 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + options.ConfigureRefresh(refresh => + { + refresh.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher2 = options.GetRefresher(); + }) + .Build(); + + // Verify initial values for both configurations + Assert.Equal("InitialValue1", config1[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config1[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("False", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("False", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + + Assert.Equal("InitialValue1", config2[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config2[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("False", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("False", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + + // Update all values in the store + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting2", "UpdatedValue2")); + + // Update the feature flags + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + secondFeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature2"",""description"":""Second test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + // Update the sentinel key to trigger refresh + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "Updated")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Act - Refresh both configurations + await refresher1.RefreshAsync(); + await refresher2.RefreshAsync(); + + // Assert - Both configurations should be updated the same way + // For config1 (Register with refreshAll: true) + Assert.Equal("UpdatedValue1", config1[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("UpdatedValue2", config1[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("True", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("True", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + + // For config2 (RegisterAll) + Assert.Equal("UpdatedValue1", config2[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("UpdatedValue2", config2[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("True", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("True", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + + // Test deleting a key and a feature flag + await _configClient.DeleteConfigurationSettingAsync($"{testContext.KeyPrefix}:Setting2"); + await _configClient.DeleteConfigurationSettingAsync(secondFeatureFlagKey); + + // Update the sentinel key again to trigger refresh + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "UpdatedAgain")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Refresh both configurations again + await refresher1.RefreshAsync(); + await refresher2.RefreshAsync(); + + // Both configurations should have removed the deleted key-value and feature flag + Assert.Equal("UpdatedValue1", config1[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Null(config1[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("True", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Null(config1[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + + Assert.Equal("UpdatedValue1", config2[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Null(config2[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("True", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Null(config2[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + } + + [Fact] + public async Task HandlesFailoverOnStartup() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("FailoverStartup"); + await SetupKeyValues(testContext); + IConfigurationRefresher refresher = null; + + string connectionString = _connectionString; + + // Create a connection string that will fail + string primaryConnectionString = ConnectionStringUtils.Build( + TestHelpers.PrimaryConfigStoreEndpoint, + ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.IdSection), + ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.SecretSection)); + string secondaryConnectionString = connectionString; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(new List { primaryConnectionString, secondaryConnectionString }); + options.Select($"{testContext.KeyPrefix}:*"); + + // Configure refresh + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, refreshAll: true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + } + + [Fact] + public async Task LoadSnapshot_RetrievesValuesFromSnapshot() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("SnapshotTest"); + await SetupKeyValues(testContext); + string snapshotName = $"snapshot-{testContext.KeyPrefix}"; + + // Create a snapshot with the test keys + await CreateSnapshot(snapshotName, new List { new ConfigurationSettingsFilter(testContext.KeyPrefix + "*") }); + + // Update values after snapshot is taken to verify snapshot has original values + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedAfterSnapshot")); + + // Act - Load configuration from snapshot + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(snapshotName); + }) + .Build(); + + // Assert - Should have original values from snapshot, not updated values + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + + // Cleanup - Delete the snapshot + await _configClient.ArchiveSnapshotAsync(snapshotName); + } + + [Fact] + public async Task LoadSnapshot_ThrowsException_WhenSnapshotDoesNotExist() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("NonExistentSnapshotTest"); + string nonExistentSnapshotName = $"snapshot-does-not-exist-{Guid.NewGuid()}"; + + // Act & Assert - Loading a non-existent snapshot should throw + var exception = await Assert.ThrowsAsync(() => + { + return Task.FromResult(new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(nonExistentSnapshotName); + }) + .Build()); + }); + + // Verify the exception message contains snapshot name + Assert.Contains(nonExistentSnapshotName, exception.Message); + } + + [Fact] + public async Task LoadMultipleSnapshots_MergesConfigurationCorrectly() + { + // Arrange - Setup test-specific keys for two separate snapshots + TestContext testContext1 = CreateTestContext("SnapshotMergeTest1"); + await SetupKeyValues(testContext1); + TestContext testContext2 = CreateTestContext("SnapshotMergeTest2"); + await SetupKeyValues(testContext2); + + // Create specific values for second snapshot + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{testContext2.KeyPrefix}:UniqueKey", "UniqueValue")); + + string snapshotName1 = $"snapshot-{testContext1.KeyPrefix}"; + string snapshotName2 = $"snapshot-{testContext2.KeyPrefix}"; + + // Create snapshots + await CreateSnapshot(snapshotName1, new List { new ConfigurationSettingsFilter(testContext1.KeyPrefix + "*") }); + await CreateSnapshot(snapshotName2, new List { new ConfigurationSettingsFilter(testContext2.KeyPrefix + "*") }); + + try + { + // Act - Load configuration from both snapshots + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(snapshotName1); + options.SelectSnapshot(snapshotName2); + }) + .Build(); + + // Assert - Should have values from both snapshots + Assert.Equal("InitialValue1", config[$"{testContext1.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue1", config[$"{testContext2.KeyPrefix}:Setting1"]); + Assert.Equal("UniqueValue", config[$"{testContext2.KeyPrefix}:UniqueKey"]); + } + finally + { + // Cleanup - Delete the snapshots + await _configClient.ArchiveSnapshotAsync(snapshotName1); + await _configClient.ArchiveSnapshotAsync(snapshotName2); + } + } + + [Fact] + public async Task SnapshotCompositionTypes_AreHandledCorrectly() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("SnapshotCompositionTest"); + await SetupKeyValues(testContext); + string keyOnlySnapshotName = $"snapshot-key-{testContext.KeyPrefix}"; + string invalidCompositionSnapshotName = $"snapshot-invalid-{testContext.KeyPrefix}"; + + // Create a snapshot with the test keys + var settingsToInclude = new List + { + new ConfigurationSettingsFilter($"{testContext.KeyPrefix}:*") + }; + + ConfigurationSnapshot keyOnlySnapshot = new ConfigurationSnapshot(settingsToInclude); + + keyOnlySnapshot.SnapshotComposition = SnapshotComposition.Key; + + // Create the snapshot + await _configClient.CreateSnapshotAsync(WaitUntil.Completed, keyOnlySnapshotName, keyOnlySnapshot); + + ConfigurationSnapshot invalidSnapshot = new ConfigurationSnapshot(settingsToInclude); + + invalidSnapshot.SnapshotComposition = SnapshotComposition.KeyLabel; + + // Create the snapshot + await _configClient.CreateSnapshotAsync(WaitUntil.Completed, invalidCompositionSnapshotName, invalidSnapshot); + + try + { + // Act & Assert - Loading a key-only snapshot should work + var config1 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(keyOnlySnapshotName); + }) + .Build(); + + Assert.Equal("InitialValue1", config1[$"{testContext.KeyPrefix}:Setting1"]); + + // Act & Assert - Loading a snapshot with invalid composition should throw + var exception = await Assert.ThrowsAsync(() => + { + return Task.FromResult(new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(invalidCompositionSnapshotName); + }) + .Build()); + }); + + // Verify the exception message mentions composition type + Assert.Contains("SnapshotComposition", exception.Message); + Assert.Contains("key", exception.Message); + Assert.Contains("label", exception.Message); + } + finally + { + // Cleanup - Delete the snapshots + await _configClient.ArchiveSnapshotAsync(keyOnlySnapshotName); + await _configClient.ArchiveSnapshotAsync(invalidCompositionSnapshotName); + } + } + + [Fact] + public async Task SnapshotWithFeatureFlags_LoadsConfigurationCorrectly() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("SnapshotFeatureFlagTest"); + await SetupFeatureFlags(testContext); + string snapshotName = $"snapshot-ff-{testContext.KeyPrefix}"; + + // Update the feature flag to be enabled before creating the snapshot + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + // Create a snapshot with the test keys + var settingsToInclude = new List + { + new ConfigurationSettingsFilter($".appconfig.featureflag/{testContext.KeyPrefix}*") + }; + + ConfigurationSnapshot snapshot = new ConfigurationSnapshot(settingsToInclude); + + snapshot.SnapshotComposition = SnapshotComposition.Key; + + // Create the snapshot + await _configClient.CreateSnapshotAsync(WaitUntil.Completed, snapshotName, snapshot); + + // Update feature flag to disabled after creating snapshot + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":false}", + contentType: FeatureFlagContentType)); + + try + { + // Act - Load configuration from snapshot with feature flags + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(); + options.SelectSnapshot(snapshotName); + }) + .Build(); + + // Assert - Should have feature flag enabled state from snapshot + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + } + finally + { + // Cleanup - Delete the snapshot + await _configClient.ArchiveSnapshotAsync(snapshotName); + } + } + + [Fact] + public async Task CallOrdering_SnapshotsWithSelectAndFeatureFlags() + { + // Arrange - Setup test-specific keys for multiple snapshots + TestContext mainContext = CreateTestContext("SnapshotOrdering"); + await SetupKeyValues(mainContext); + await SetupFeatureFlags(mainContext); + + TestContext secondContext = CreateTestContext("SnapshotOrdering2"); + await SetupKeyValues(secondContext); + await SetupFeatureFlags(secondContext); + + TestContext thirdContext = CreateTestContext("SnapshotOrdering3"); + await SetupKeyValues(thirdContext); + + // Create specific values for each snapshot + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{mainContext.KeyPrefix}:UniqueMain", "MainValue")); + + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{secondContext.KeyPrefix}:UniqueSecond", "SecondValue")); + + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{thirdContext.KeyPrefix}:UniqueThird", "ThirdValue")); + + // Create additional feature flags + string secondFeatureFlagKey = $".appconfig.featureflag/{mainContext.KeyPrefix}Feature2"; + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + secondFeatureFlagKey, + @"{""id"":""" + mainContext.KeyPrefix + @"Feature2"",""description"":""Second test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + string thirdFeatureFlagKey = $".appconfig.featureflag/{secondContext.KeyPrefix}Feature"; + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + thirdFeatureFlagKey, + @"{""id"":""" + secondContext.KeyPrefix + @"Feature"",""description"":""Third test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + // Create snapshots + string snapshot1 = $"snapshot-{mainContext.KeyPrefix}-1"; + string snapshot2 = $"snapshot-{secondContext.KeyPrefix}-2"; + string snapshot3 = $"snapshot-{thirdContext.KeyPrefix}-3"; + + await CreateSnapshot(snapshot1, new List { new ConfigurationSettingsFilter(mainContext.KeyPrefix + "*") }); + await CreateSnapshot(snapshot2, new List { new ConfigurationSettingsFilter(secondContext.KeyPrefix + "*") }); + await CreateSnapshot(snapshot3, new List { new ConfigurationSettingsFilter(thirdContext.KeyPrefix + "*") }); + + try + { + // Test different orderings of SelectSnapshot, Select and UseFeatureFlags + + // Order 1: SelectSnapshot -> Select -> UseFeatureFlags + var config1 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(snapshot1); + options.Select($"{mainContext.KeyPrefix}:*"); + options.UseFeatureFlags(ff => + { + ff.Select($"{mainContext.KeyPrefix}Feature*"); + }); + }) + .Build(); + + // Order 2: Select -> SelectSnapshot -> UseFeatureFlags + var config2 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{secondContext.KeyPrefix}:*"); + options.SelectSnapshot(snapshot2); + options.UseFeatureFlags(ff => + { + ff.Select($"{secondContext.KeyPrefix}Feature*"); + }); + }) + .Build(); + + // Order 3: UseFeatureFlags -> SelectSnapshot -> Select + var config3 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(); + options.SelectSnapshot(snapshot3); + options.Select($"{thirdContext.KeyPrefix}:*"); + }) + .Build(); + + // Order 4: Multiple snapshots with interleaved operations + var config4 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(snapshot1); + options.UseFeatureFlags(ff => + { + ff.Select($"{mainContext.KeyPrefix}Feature*"); + }); + options.SelectSnapshot(snapshot2); + options.Select($"{secondContext.KeyPrefix}:*"); + options.UseFeatureFlags(ff => + { + ff.Select($"{secondContext.KeyPrefix}Feature*"); + }); + options.SelectSnapshot(snapshot3); + }) + .Build(); + + // Verify config1: Should have values from snapshot1 and feature flags from mainContext + Assert.Equal("InitialValue1", config1[$"{mainContext.KeyPrefix}:Setting1"]); + Assert.Equal("MainValue", config1[$"{mainContext.KeyPrefix}:UniqueMain"]); + Assert.Equal("False", config1[$"FeatureManagement:{mainContext.KeyPrefix}Feature"]); + Assert.Equal("True", config1[$"FeatureManagement:{mainContext.KeyPrefix}Feature2"]); + + // Verify config2: Should have values from snapshot2 and feature flags from secondContext + Assert.Equal("InitialValue1", config2[$"{secondContext.KeyPrefix}:Setting1"]); + Assert.Equal("SecondValue", config2[$"{secondContext.KeyPrefix}:UniqueSecond"]); + Assert.Equal("True", config2[$"FeatureManagement:{secondContext.KeyPrefix}Feature"]); + + // Verify config3: Should have values from snapshot3 and all feature flags + Assert.Equal("InitialValue1", config3[$"{thirdContext.KeyPrefix}:Setting1"]); + Assert.Equal("ThirdValue", config3[$"{thirdContext.KeyPrefix}:UniqueThird"]); + Assert.Equal("False", config3[$"FeatureManagement:{mainContext.KeyPrefix}Feature"]); + Assert.Equal("True", config3[$"FeatureManagement:{secondContext.KeyPrefix}Feature"]); + + // Verify config4: Should have values from all three snapshots + Assert.Equal("InitialValue1", config4[$"{mainContext.KeyPrefix}:Setting1"]); + Assert.Equal("MainValue", config4[$"{mainContext.KeyPrefix}:UniqueMain"]); + Assert.Equal("InitialValue1", config4[$"{secondContext.KeyPrefix}:Setting1"]); + Assert.Equal("SecondValue", config4[$"{secondContext.KeyPrefix}:UniqueSecond"]); + Assert.Equal("InitialValue1", config4[$"{thirdContext.KeyPrefix}:Setting1"]); + Assert.Equal("ThirdValue", config4[$"{thirdContext.KeyPrefix}:UniqueThird"]); + Assert.Equal("False", config4[$"FeatureManagement:{mainContext.KeyPrefix}Feature"]); + Assert.Equal("True", config4[$"FeatureManagement:{mainContext.KeyPrefix}Feature2"]); + Assert.Equal("True", config4[$"FeatureManagement:{secondContext.KeyPrefix}Feature"]); + } + finally + { + // Cleanup - Delete the snapshots + await _configClient.ArchiveSnapshotAsync(snapshot1); + await _configClient.ArchiveSnapshotAsync(snapshot2); + await _configClient.ArchiveSnapshotAsync(snapshot3); + } + } + + [Fact] + public async Task KeyVaultReferences_ResolveCorrectly() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("KeyVaultReference"); + await SetupKeyVaultReferences(testContext); + + // Act - Create configuration with Key Vault support + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*", KeyVaultReferenceLabel); + options.ConfigureKeyVault(kv => kv.SetCredential(_defaultAzureCredential)); + }) + .Build(); + + // Assert - Key Vault reference should be resolved to the secret value + Assert.Equal("SecretValue", config[testContext.KeyVaultReferenceKey]); + } + + /// + /// Tests that Key Vault secrets are properly cached to avoid unnecessary requests. + /// + [Fact] + public async Task KeyVaultReference_UsesCache_DoesNotCallKeyVaultAgain() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("KeyVaultCacheTest"); + await SetupKeyVaultReferences(testContext); + + // Create a monitoring client to track calls to Key Vault + int requestCount = 0; + var testSecretClient = new SecretClient( + _keyVaultEndpoint, + _defaultAzureCredential, + new SecretClientOptions + { + Transport = new HttpPipelineTransportWithRequestCount(() => requestCount++) + }); + + // Act + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*", KeyVaultReferenceLabel); + options.ConfigureKeyVault(kv => + { + kv.Register(testSecretClient); + }); + }) + .Build(); + + // First access should resolve from Key Vault + string firstValue = config[testContext.KeyVaultReferenceKey]; + int firstRequestCount = requestCount; + + // Second access should use the cache + string secondValue = config[testContext.KeyVaultReferenceKey]; + int secondRequestCount = requestCount; + + // Assert + Assert.Equal(testContext.SecretValue, firstValue); + Assert.Equal(testContext.SecretValue, secondValue); + Assert.Equal(1, firstRequestCount); // Should make exactly one request + Assert.Equal(firstRequestCount, secondRequestCount); // No additional requests for the second access + } + + [Fact] + public async Task KeyVaultReference_DifferentRefreshIntervals() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("KeyVaultDifferentIntervals"); + IConfigurationRefresher refresher = null; + + // Create a secret in Key Vault with short refresh interval + string secretName1 = $"test-secret1-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + string secretValue1 = $"SecretValue1-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + await _secretClient.SetSecretAsync(secretName1, secretValue1); + + // Create another secret in Key Vault with long refresh interval + string secretName2 = $"test-secret2-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + string secretValue2 = $"SecretValue2-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + await _secretClient.SetSecretAsync(secretName2, secretValue2); + + // Create Key Vault references in App Configuration + string keyVaultUri = _keyVaultEndpoint.ToString().TrimEnd('/'); + string kvRefKey1 = $"{testContext.KeyPrefix}:KeyVaultRef1"; + string kvRefKey2 = $"{testContext.KeyPrefix}:KeyVaultRef2"; + + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + kvRefKey1, + $@"{{""uri"":""{keyVaultUri}/secrets/{secretName1}""}}", + label: KeyVaultReferenceLabel, + contentType: KeyVaultConstants.ContentType + "; charset=utf-8")); + + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + kvRefKey2, + $@"{{""uri"":""{keyVaultUri}/secrets/{secretName2}""}}", + label: KeyVaultReferenceLabel, + contentType: KeyVaultConstants.ContentType + "; charset=utf-8")); + + // Act - Create configuration with different refresh intervals + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*", KeyVaultReferenceLabel); + options.ConfigureKeyVault(kv => + { + kv.SetCredential(_defaultAzureCredential); + // Set different refresh intervals for each secret + kv.SetSecretRefreshInterval(kvRefKey1, TimeSpan.FromSeconds(60)); // Short interval + kv.SetSecretRefreshInterval(kvRefKey2, TimeSpan.FromDays(1)); // Long interval + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal(secretValue1, config[kvRefKey1]); + Assert.Equal(secretValue2, config[kvRefKey2]); + + // Update both secrets in Key Vault + string updatedValue1 = $"UpdatedValue1-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + string updatedValue2 = $"UpdatedValue2-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + + await _secretClient.SetSecretAsync(secretName1, updatedValue1); + await _secretClient.SetSecretAsync(secretName2, updatedValue2); + + // Wait for the short interval cache to expire + await Task.Delay(TimeSpan.FromSeconds(61)); + + // Refresh the configuration + await refresher.RefreshAsync(); + + // Assert - Only the first secret should be refreshed due to having a short interval + Assert.Equal(updatedValue1, config[kvRefKey1]); // Updated - short refresh interval + Assert.Equal(secretValue2, config[kvRefKey2]); // Not updated - long refresh interval + } + + [Fact] + public async Task RequestTracing_SetsCorrectCorrelationContextHeader() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("RequestTracing"); + await SetupFeatureFlags(testContext); + await SetupKeyVaultReferences(testContext); + + // Used to trigger FMVer tag in request tracing + IFeatureManager featureManager; + + // Create a custom HttpPipeline that can inspect outgoing requests + var requestInspector = new RequestInspectionHandler(); + + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{ + ""id"": """ + testContext.KeyPrefix + @"Feature"", + ""description"": ""Test feature with filters"", + ""enabled"": true, + ""variants"": [ + { + ""name"": ""LargeSize"", + ""configuration_value"": ""800px"" + }, + { + ""name"": ""MediumSize"", + ""configuration_value"": ""600px"" + }, + { + ""name"": ""SmallSize"", + ""configuration_value"": ""400px"" + } + ], + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [""Chrome"", ""Edge""] + } + }, + { + ""name"": ""TimeWindow"", + ""parameters"": { + ""Start"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds() + @")\/"", + ""End"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds() + @")\/"" + } + } + ] + } + }", + contentType: FeatureFlagContentType)); + + IConfigurationRefresher refresher = null; + + using HttpClientTransportWithRequestInspection transportWithRequestInspection = new HttpClientTransportWithRequestInspection(requestInspector); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + options.ConfigureClientOptions(clientOptions => + { + clientOptions.Transport = transportWithRequestInspection; + }); + options.ConfigureKeyVault(kv => kv.SetCredential(_defaultAzureCredential)); + options.UseFeatureFlags(); + options.LoadBalancingEnabled = true; + refresher = options.GetRefresher(); + options.ConfigureRefresh(refresh => + { + refresh.RegisterAll(); + refresh.SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + }) + .Build(); + + // Assert - Verify correlation context headers + + // Basic request should have at least the request type + Assert.Contains(RequestTracingConstants.RequestTypeKey, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains("Startup", requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.KeyVaultConfiguredTag, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.LoadBalancingEnabledTag, requestInspector.CorrelationContextHeaders.Last()); + + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + await Task.Delay(1500); + await refresher.RefreshAsync(); + + Assert.Contains("Watch", requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.FeatureFlagFilterTypeKey, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.TimeWindowFilter, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.CustomFilter, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.FeatureFlagMaxVariantsKey, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains($"{RequestTracingConstants.FeatureManagementVersionKey}=4.0.0", requestInspector.CorrelationContextHeaders.Last()); + } + + [Fact] + public async Task TagFilters() + { + TestContext testContext = CreateTestContext("TagFilters"); + await SetupTaggedSettings(testContext); + string keyPrefix = testContext.KeyPrefix; + + // Test case 1: Basic tag filtering with single tag + var config1 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development" }); + options.UseFeatureFlags(ff => + { + ff.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development" }); + }); + }) + .Build(); + + // Assert - Should only get settings with Environment=Development tag + Assert.Equal("Value1", config1[$"{keyPrefix}:TaggedSetting1"]); + Assert.Equal("Value3", config1[$"{keyPrefix}:TaggedSetting3"]); + Assert.Null(config1[$"{keyPrefix}:TaggedSetting2"]); + Assert.Null(config1[$"{keyPrefix}:TaggedSetting4"]); + Assert.Null(config1[$"{keyPrefix}:TaggedSetting5"]); + + // Feature flags should be filtered as well + Assert.Equal("True", config1[$"FeatureManagement:{keyPrefix}:FeatureDev"]); + Assert.Null(config1[$"FeatureManagement:{keyPrefix}:FeatureProd"]); + Assert.Null(config1[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]); + Assert.Null(config1[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]); + + // Test case 2: Multiple tag filters (AND condition) + var config2 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development", "App=TestApp" }); + options.UseFeatureFlags(ff => + { + ff.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development", "App=TestApp" }); + }); + }) + .Build(); + + // Assert - Should only get settings with both Environment=Development AND App=TestApp tags + Assert.Equal("Value1", config2[$"{keyPrefix}:TaggedSetting1"]); + Assert.Null(config2[$"{keyPrefix}:TaggedSetting2"]); + Assert.Null(config2[$"{keyPrefix}:TaggedSetting3"]); + Assert.Null(config2[$"{keyPrefix}:TaggedSetting4"]); + Assert.Null(config2[$"{keyPrefix}:TaggedSetting5"]); + + // Feature flags + Assert.Equal("True", config2[$"FeatureManagement:{keyPrefix}:FeatureDev"]); + Assert.Null(config2[$"FeatureManagement:{keyPrefix}:FeatureProd"]); + Assert.Null(config2[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]); + Assert.Null(config2[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]); + + // Test case 3: Special characters in tags + var config3 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "Special:Tag=Value:With:Colons" }); + options.UseFeatureFlags(ff => + { + ff.Select($"{keyPrefix}:*", tagFilters: new[] { "Special:Tag=Value:With:Colons" }); + }); + }) + .Build(); + + // Assert - Should only get settings with the special character tag + Assert.Equal("Value4", config3[$"{keyPrefix}:TaggedSetting4"]); + Assert.Null(config3[$"{keyPrefix}:TaggedSetting1"]); + + Assert.Null(config3[$"{keyPrefix}:TaggedSetting2"]); + Assert.Null(config3[$"{keyPrefix}:TaggedSetting3"]); + Assert.Null(config3[$"{keyPrefix}:TaggedSetting5"]); + + // Feature flags + Assert.Equal("True", config3[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]); + Assert.Null(config3[$"FeatureManagement:{keyPrefix}:FeatureDev"]); + Assert.Null(config3[$"FeatureManagement:{keyPrefix}:FeatureProd"]); + Assert.Null(config3[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]); + + // Test case 4: Tag with @ symbol + var config4 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "Tag@With@At=Value@With@At" }); + }) + .Build(); + + // Assert - Should only get settings with the @ symbol tag + Assert.Equal("Value4", config4[$"{keyPrefix}:TaggedSetting4"]); + Assert.Null(config4[$"{keyPrefix}:TaggedSetting1"]); + Assert.Null(config4[$"{keyPrefix}:TaggedSetting2"]); + Assert.Null(config4[$"{keyPrefix}:TaggedSetting3"]); + Assert.Null(config4[$"{keyPrefix}:TaggedSetting5"]); + + // Test case 5: Empty and null tag values + var config5 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "EmptyTag=", $"NullTag={TagValue.Null}" }); + options.UseFeatureFlags(ff => + { + ff.Select($"{keyPrefix}:*", tagFilters: new[] { "EmptyTag=", $"NullTag={TagValue.Null}" }); + }); + }) + .Build(); + + // Assert - Should only get settings with both empty and null tag values + Assert.Equal("Value5", config5[$"{keyPrefix}:TaggedSetting5"]); + Assert.Null(config5[$"{keyPrefix}:TaggedSetting1"]); + Assert.Null(config5[$"{keyPrefix}:TaggedSetting2"]); + Assert.Null(config5[$"{keyPrefix}:TaggedSetting3"]); + Assert.Null(config5[$"{keyPrefix}:TaggedSetting4"]); + + // Feature flags + Assert.Equal("False", config5[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]); + Assert.Null(config5[$"FeatureManagement:{keyPrefix}:FeatureDev"]); + Assert.Null(config5[$"FeatureManagement:{keyPrefix}:FeatureProd"]); + Assert.Null(config5[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]); + + // Test case 6: Interaction with refresh functionality + IConfigurationRefresher refresher = null; + var config9 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development" }); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, refreshAll: true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + // Assert initial state + Assert.Equal("Value1", config9[$"{keyPrefix}:TaggedSetting1"]); + Assert.Equal("Value3", config9[$"{keyPrefix}:TaggedSetting3"]); + + // Update a tagged setting's value + await _configClient.SetConfigurationSettingAsync( + CreateSettingWithTags( + $"{keyPrefix}:TaggedSetting1", + "UpdatedValue1", + new Dictionary { + { "Environment", "Development" }, + { "App", "TestApp" } + })); + + // Add a new setting with Development tag + await _configClient.SetConfigurationSettingAsync( + CreateSettingWithTags( + $"{keyPrefix}:TaggedSetting7", + "Value7", + new Dictionary { + { "Environment", "Development" } + })); + + // Update the sentinel key to trigger refresh + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "Updated")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Refresh the configuration + await refresher.RefreshAsync(); + + Assert.Equal("UpdatedValue1", config9[$"{keyPrefix}:TaggedSetting1"]); + Assert.Equal("Value3", config9[$"{keyPrefix}:TaggedSetting3"]); + Assert.Equal("Value7", config9[$"{keyPrefix}:TaggedSetting7"]); + Assert.Null(config9[$"{keyPrefix}:TaggedSetting2"]); + } + + private class HttpPipelineTransportWithRequestCount : HttpPipelineTransport + { + private readonly HttpClientTransport _innerTransport = new HttpClientTransport(); + private readonly Action _onRequest; + + public HttpPipelineTransportWithRequestCount(Action onRequest) + { + _onRequest = onRequest; + } + + public override Request CreateRequest() + { + return _innerTransport.CreateRequest(); + } + + public override void Process(HttpMessage message) + { + _onRequest(); + _innerTransport.Process(message); + } + + public override ValueTask ProcessAsync(HttpMessage message) + { + _onRequest(); + return _innerTransport.ProcessAsync(message); + } + } + + private class HttpClientTransportWithRequestInspection : HttpClientTransport + { + private readonly RequestInspectionHandler _inspector; + + public HttpClientTransportWithRequestInspection(RequestInspectionHandler inspector) + { + _inspector = inspector; + } + + public override async ValueTask ProcessAsync(HttpMessage message) + { + _inspector.InspectRequest(message); + await base.ProcessAsync(message); + } + } + + private class RequestInspectionHandler + { + public List CorrelationContextHeaders { get; } = new List(); + + public void InspectRequest(HttpMessage message) + { + if (message.Request.Headers.TryGetValue(RequestTracingConstants.CorrelationContextHeader, out string header)) + { + CorrelationContextHeaders.Add(header); + } + } + } + + private ConfigurationSetting CreateSettingWithTags(string key, string value, IDictionary tags) + { + var setting = new ConfigurationSetting(key, value); + + if (tags != null) + { + foreach (var tag in tags) + { + setting.Tags.Add(tag.Key, tag.Value); + } + } + + return setting; + } + + private ConfigurationSetting CreateFeatureFlagWithTags(string featureId, bool enabled, IDictionary tags) + { + string jsonValue = $@"{{ + ""id"": ""{featureId}"", + ""description"": ""Test feature flag with tags"", + ""enabled"": {enabled.ToString().ToLowerInvariant()}, + ""conditions"": {{ + ""client_filters"": [] + }} + }}"; + + var setting = new ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + featureId, + value: jsonValue) + { + ContentType = FeatureFlagContentType + }; + + if (tags != null) + { + foreach (var tag in tags) + { + setting.Tags.Add(tag.Key, tag.Value); + } + } + + return setting; + } + } +} diff --git a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj index 49c58ec6..79f47316 100644 --- a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj +++ b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj @@ -7,12 +7,17 @@ true ..\..\build\AzureAppConfiguration.snk false - false + true + + + + + @@ -23,7 +28,13 @@ - + + + + Always + + + diff --git a/tests/Tests.AzureAppConfiguration/CallbackMessageHandler.cs b/tests/Tests.AzureAppConfiguration/Unit/CallbackMessageHandler.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/CallbackMessageHandler.cs rename to tests/Tests.AzureAppConfiguration/Unit/CallbackMessageHandler.cs diff --git a/tests/Tests.AzureAppConfiguration/ClientOptionsTests.cs b/tests/Tests.AzureAppConfiguration/Unit/ClientOptionsTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/ClientOptionsTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/ClientOptionsTests.cs diff --git a/tests/Tests.AzureAppConfiguration/ConnectTests.cs b/tests/Tests.AzureAppConfiguration/Unit/ConnectTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/ConnectTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/ConnectTests.cs diff --git a/tests/Tests.AzureAppConfiguration/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/FailoverTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs diff --git a/tests/Tests.AzureAppConfiguration/HttpRequestCountPipelinePolicy.cs b/tests/Tests.AzureAppConfiguration/Unit/HttpRequestCountPipelinePolicy.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/HttpRequestCountPipelinePolicy.cs rename to tests/Tests.AzureAppConfiguration/Unit/HttpRequestCountPipelinePolicy.cs diff --git a/tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs b/tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs diff --git a/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs b/tests/Tests.AzureAppConfiguration/Unit/LoadBalancingTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/LoadBalancingTests.cs diff --git a/tests/Tests.AzureAppConfiguration/LoggingTests.cs b/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/LoggingTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs diff --git a/tests/Tests.AzureAppConfiguration/MapTests.cs b/tests/Tests.AzureAppConfiguration/Unit/MapTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/MapTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/MapTests.cs diff --git a/tests/Tests.AzureAppConfiguration/MockedConfigurationClientManager.cs b/tests/Tests.AzureAppConfiguration/Unit/MockedConfigurationClientManager.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/MockedConfigurationClientManager.cs rename to tests/Tests.AzureAppConfiguration/Unit/MockedConfigurationClientManager.cs diff --git a/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs b/tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/PushRefreshTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/RefreshTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs diff --git a/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs b/tests/Tests.AzureAppConfiguration/Unit/TagFiltersTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/TagFiltersTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/TagFiltersTests.cs diff --git a/tests/Tests.AzureAppConfiguration/TestHelper.cs b/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/TestHelper.cs rename to tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs diff --git a/tests/Tests.AzureAppConfiguration/Tests.cs b/tests/Tests.AzureAppConfiguration/Unit/Tests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/Tests.cs rename to tests/Tests.AzureAppConfiguration/Unit/Tests.cs From 96968abd809a8d308b6ccdc54bb4fa746c20c77a Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 29 May 2025 12:11:52 +0800 Subject: [PATCH 18/37] Support health check (#644) * support health check * update * reord last successful time in ExecuteWithFailoverPolicy * make health check compatible with DI * add health check for each provider instance * update * update comment --- .../AzureAppConfigurationHealthCheck.cs | 75 ++++++++++ ...figurationHealthChecksBuilderExtensions.cs | 45 ++++++ .../AzureAppConfigurationProvider.cs | 27 +++- .../Constants/HealthCheckConstants.cs | 14 ++ ...Configuration.AzureAppConfiguration.csproj | 1 + .../HealthCheckTest.cs | 140 ++++++++++++++++++ 6 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs create mode 100644 tests/Tests.AzureAppConfiguration/HealthCheckTest.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs new file mode 100644 index 00000000..76b706c0 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using System.Threading; +using System.Linq; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class AzureAppConfigurationHealthCheck : IHealthCheck + { + private static readonly PropertyInfo _propertyInfo = typeof(ChainedConfigurationProvider).GetProperty("Configuration", BindingFlags.Public | BindingFlags.Instance); + private readonly IEnumerable _healthChecks; + + public AzureAppConfigurationHealthCheck(IConfiguration configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + var healthChecks = new List(); + var configurationRoot = configuration as IConfigurationRoot; + FindHealthChecks(configurationRoot, healthChecks); + + _healthChecks = healthChecks; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!_healthChecks.Any()) + { + return HealthCheckResult.Unhealthy(HealthCheckConstants.NoProviderFoundMessage); + } + + foreach (IHealthCheck healthCheck in _healthChecks) + { + var result = await healthCheck.CheckHealthAsync(context, cancellationToken).ConfigureAwait(false); + + if (result.Status == HealthStatus.Unhealthy) + { + return result; + } + } + + return HealthCheckResult.Healthy(); + } + + private void FindHealthChecks(IConfigurationRoot configurationRoot, List healthChecks) + { + if (configurationRoot != null) + { + foreach (IConfigurationProvider provider in configurationRoot.Providers) + { + if (provider is AzureAppConfigurationProvider appConfigurationProvider) + { + healthChecks.Add(appConfigurationProvider); + } + else if (provider is ChainedConfigurationProvider chainedProvider) + { + if (_propertyInfo != null) + { + var chainedProviderConfigurationRoot = _propertyInfo.GetValue(chainedProvider) as IConfigurationRoot; + FindHealthChecks(chainedProviderConfigurationRoot, healthChecks); + } + } + } + } + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs new file mode 100644 index 00000000..f006b746 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods to configure . + /// + public static class AzureAppConfigurationHealthChecksBuilderExtensions + { + /// + /// Add a health check for Azure App Configuration to given . + /// + /// The to add to. + /// A factory to obtain instance. + /// The health check name. + /// The that should be reported when the health check fails. + /// A list of tags that can be used to filter sets of health checks. + /// A representing the timeout of the check. + /// The provided health checks builder. + public static IHealthChecksBuilder AddAzureAppConfiguration( + this IHealthChecksBuilder builder, + Func factory = default, + string name = HealthCheckConstants.HealthCheckRegistrationName, + HealthStatus failureStatus = default, + IEnumerable tags = default, + TimeSpan? timeout = default) + { + return builder.Add(new HealthCheckRegistration( + name ?? HealthCheckConstants.HealthCheckRegistrationName, + sp => new AzureAppConfigurationHealthCheck( + factory?.Invoke(sp) ?? sp.GetRequiredService()), + failureStatus, + tags, + timeout)); + } + } +} + diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index a83c7413..6b100f8d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -5,6 +5,7 @@ using Azure.Data.AppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -21,7 +22,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { - internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher, IDisposable + internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher, IHealthCheck, IDisposable { private readonly ActivitySource _activitySource = new ActivitySource(ActivityNames.AzureAppConfigurationActivitySource); private bool _optional; @@ -53,6 +54,10 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private Logger _logger = new Logger(); private ILoggerFactory _loggerFactory; + // For health check + private DateTimeOffset? _lastSuccessfulAttempt = null; + private DateTimeOffset? _lastFailedAttempt = null; + private class ConfigurationClientBackoffStatus { public int FailedAttempts { get; set; } @@ -256,6 +261,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken) _logger.LogDebug(LogHelper.BuildRefreshSkippedNoClientAvailableMessage()); + _lastFailedAttempt = DateTime.UtcNow; + return; } @@ -571,6 +578,22 @@ public void ProcessPushNotification(PushNotification pushNotification, TimeSpan? } } + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!_lastSuccessfulAttempt.HasValue) + { + return HealthCheckResult.Unhealthy(HealthCheckConstants.LoadNotCompletedMessage); + } + + if (_lastFailedAttempt.HasValue && + _lastSuccessfulAttempt.Value < _lastFailedAttempt.Value) + { + return HealthCheckResult.Unhealthy(HealthCheckConstants.RefreshFailedMessage); + } + + return HealthCheckResult.Healthy(); + } + private void SetDirty(TimeSpan? maxDelay) { DateTimeOffset nextRefreshTime = AddRandomDelay(DateTimeOffset.UtcNow, maxDelay ?? DefaultMaxSetDirtyDelay); @@ -1158,6 +1181,7 @@ private async Task ExecuteWithFailOverPolicyAsync( success = true; _lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient); + _lastSuccessfulAttempt = DateTime.UtcNow; return result; } @@ -1183,6 +1207,7 @@ private async Task ExecuteWithFailOverPolicyAsync( { if (!success && backoffAllClients) { + _lastFailedAttempt = DateTime.UtcNow; _logger.LogWarning(LogHelper.BuildLastEndpointFailedMessage(previousEndpoint?.ToString())); do diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs new file mode 100644 index 00000000..06939815 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class HealthCheckConstants + { + public const string HealthCheckRegistrationName = "Microsoft.Extensions.Configuration.AzureAppConfiguration"; + public const string NoProviderFoundMessage = "No configuration provider is found."; + public const string LoadNotCompletedMessage = "The initial load is not completed."; + public const string RefreshFailedMessage = "The last refresh attempt failed."; + } +} 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 87c4251d..7934e5e4 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/HealthCheckTest.cs b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs new file mode 100644 index 00000000..9dce8e82 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs @@ -0,0 +1,140 @@ +// 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 Microsoft.Extensions.Diagnostics.HealthChecks; +using Moq; +using System.Threading; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; + +namespace Tests.AzureAppConfiguration +{ + public class HealthCheckTest + { + readonly List kvCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting("TestKey1", "TestValue1", "label", + eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"), + contentType:"text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey2", "TestValue2", "label", + eTag: new ETag("31c38369-831f-4bf1-b9ad-79db56c8b989"), + contentType: "text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey3", "TestValue3", "label", + + eTag: new ETag("bb203f2b-c113-44fc-995d-b933c2143339"), + contentType: "text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey4", "TestValue4", "label", + eTag: new ETag("3ca43b3e-d544-4b0c-b3a2-e7a7284217a2"), + contentType: "text"), + }; + + [Fact] + public async Task HealthCheckTests_ReturnsHealthyWhenInitialLoadIsCompleted() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); + + Assert.True(config["TestKey1"] == "TestValue1"); + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() + { + IConfigurationRefresher refresher = null; + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)) + .Throws(new RequestFailedException(503, "Request failed.")) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.MinBackoffDuration = TimeSpan.FromSeconds(2); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); + + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + + // Wait for the refresh interval to expire + Thread.Sleep(1000); + + await refresher.TryRefreshAsync(); + result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Unhealthy, result.Status); + + // Wait for client backoff to end + Thread.Sleep(3000); + + await refresher.RefreshAsync(); + result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task HealthCheckTests_RegisterAzureAppConfigurationHealthCheck() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddLogging(); // add logging for health check service + services.AddHealthChecks() + .AddAzureAppConfiguration(); + var provider = services.BuildServiceProvider(); + var healthCheckService = provider.GetRequiredService(); + + var result = await healthCheckService.CheckHealthAsync(); + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Contains(HealthCheckConstants.HealthCheckRegistrationName, result.Entries.Keys); + Assert.Equal(HealthStatus.Healthy, result.Entries[HealthCheckConstants.HealthCheckRegistrationName].Status); + } + } +} From b721c9ed7998389eff6812c871f9d1a2c06a83b0 Mon Sep 17 00:00:00 2001 From: AMER JUSUPOVIC Date: Tue, 3 Jun 2025 14:17:38 -0700 Subject: [PATCH 19/37] Undo package version change --- .../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 21d5bfc0..fda406b9 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.2.0 + 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 64b28f0f..eb43831e 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.2.0 + 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 7934e5e4..d3b15398 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -38,7 +38,7 @@ - 8.2.0 + 8.2.0-preview From 41d83206a5bb962ee5f6d4586981f97e7df82d16 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Tue, 17 Jun 2025 12:11:44 -0700 Subject: [PATCH 20/37] Add Copilot instructions (#667) * add copilot instructions * fix grammar * Update .github/code-gen-instructions.md Co-authored-by: Jimmy Campbell --------- Co-authored-by: Jimmy Campbell --- .github/code-gen-instructions.md | 93 ++++++++++++++++++++++++++++++++ .github/copilot-instructions.md | 3 ++ 2 files changed, 96 insertions(+) create mode 100644 .github/code-gen-instructions.md create mode 100644 .github/copilot-instructions.md diff --git a/.github/code-gen-instructions.md b/.github/code-gen-instructions.md new file mode 100644 index 00000000..edddb668 --- /dev/null +++ b/.github/code-gen-instructions.md @@ -0,0 +1,93 @@ +# AppConfiguration-DotnetProvider Coding Guidelines + +This document outlines coding guidelines for the Azure App Configuration .NET Provider repository. Follow these guidelines when generating or modifying code. + +## General Guidelines + +1. **Exception Handling**: + * When adding error handling, always catch specific exceptions and avoid catching the base `Exception` class in catch blocks. + * Throw specific exception types (e.g., `ArgumentNullException`, `FormatException`, custom exceptions) rather than generic `System.Exception`. + * Include the parameter name when throwing `ArgumentNullException` using `nameof()`. + +2. **Variable Declaration**: + * Never use `var` to declare a variable if the assignment doesn't include the type or the type isn't immediately obvious. + * Use explicit type names for fields, properties, method parameters, and return types. + * Use `var` only when the type is obvious from the right-hand side (e.g., `var user = new User();`). + +3. **Null Handling**: + * Validate arguments in public methods and constructors with explicit null checks. + * Use explicit `if (argument == null) throw new ArgumentNullException(nameof(argument));` checks at the beginning of methods/constructors. + * Avoid using the null-forgiving operator (`!`) unless absolutely necessary. + +4. **Asynchronous Programming**: + * All async methods should accept a `CancellationToken` as the last parameter. + * Pass the `cancellationToken` down the call stack to all subsequent asynchronous operations. + * Use `Task` or `Task` for asynchronous methods. + +5. **LINQ and Collections**: + * Prefer simple, readable LINQ queries. + * Break down complex LINQ queries into separate statements with intermediate variables. + * Use collection interfaces (e.g., `IList`, `IReadOnlyList`) in parameter and return types. + +6. **Resource Management**: + * Wrap `IDisposable` instances in `using` statements to ensure proper disposal. + * Implement `IDisposable` correctly if your class manages disposable objects. + +7. **Dependency Injection**: + * Use constructor injection for dependencies. + * Store injected dependencies in `private readonly` fields. + * Validate injected dependencies for null in the constructor. + +8. **Naming Conventions**: + * Use `PascalCase` for classes, interfaces, enums, methods, properties, and constants. + * Use `camelCase` for local variables and method parameters. + * Prefix private fields with an underscore (`_`). + * Define constants for error messages and other string literals. + +9. **Comments**: + * Only add comments when it's not obvious what the code is doing. For example, if a variable name is already fairly descriptive, a comment isn't needed explaining its name. + * Add summary comments to public classes and members of those classes. + +## AppConfiguration-Specific Guidelines + +1. **Feature Flag Handling**: + * Validate feature flag data structure before processing. + * Handle different feature flag schemas (Microsoft vs .NET) appropriately. + * Use proper error handling when parsing feature flags with clear error messages. + +2. **Configuration Key-Value Processing**: + * Follow adapter pattern for processing different configuration types. + * Properly handle key-value pairs with appropriate content type detection. + * Use `KeyValuePair` for configuration values. + +3. **Content Type Handling**: + * Validate content types before processing. + * Use appropriate content type constants. + * Check content type using extension methods like `IsFeatureFlag()`. + +4. **JSON Parsing**: + * Use `Utf8JsonReader` for performance-critical JSON parsing. + * Validate JSON structure and provide clear error messages for malformed input. + * Handle JSON token types appropriately with proper error handling. + +5. **Refresh Mechanisms**: + * Implement proper configuration refresh patterns. + * Use sentinel-based refresh mechanisms when appropriate. + * Handle refresh failures gracefully. + +## Performance Considerations + +1. **String Handling**: + * Use `StringBuilder` for concatenating multiple strings. + * Define string constants for recurring strings. + * Use string interpolation instead of string concatenation when appropriate. + +2. **Collections**: + * Initialize collections with estimated capacity when possible. + * Use appropriate collection types for the use case (e.g., `List`, `Dictionary`). + * Avoid unnecessary collection allocations. + +3. **Memory Management**: + * Use `Span` and `ReadOnlySpan` for high-performance scenarios. + * Minimize allocations in performance-critical paths. + * Be mindful of closure allocations in LINQ and lambdas. \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..29084da3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +This is the Azure App Configuration .NET Provider codebase. The service abides by coding guidelines specified in the `github/code-gen-instructions.md` file. + +When suggesting code changes, do not modify the files directly. Instead, provide a detailed explanation of the changes you would make and ask for confirmation before editing the files. You may create markdown files to demonstrate the changes you would like to make. From f2128f42dec71fe656e9826c58bdf63559070de0 Mon Sep 17 00:00:00 2001 From: Richard Muniu Date: Wed, 25 Jun 2025 01:55:13 +0300 Subject: [PATCH 21/37] Add CodeQL exclusions for code samples --- CodeQL.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 CodeQL.yml diff --git a/CodeQL.yml b/CodeQL.yml new file mode 100644 index 00000000..01662492 --- /dev/null +++ b/CodeQL.yml @@ -0,0 +1,10 @@ +path_classifiers: + docs: + # Documentation + - "examples" # Exclude code samples from scan results + library: + # Library code + - "" + generated: + # Generated code + - "" From d5a0462451fe96bef3152187ea91651a871eb2c8 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:57:09 -0700 Subject: [PATCH 22/37] Fix ConfigStoreDemo sample for CodeQL alerts (#677) * initial fixes to jquery for codeql alerts * add other wwwroot files * remove comment * redo line endings on configstoredemo sample files * update site css to fit title * fix title spacing * update to aspnet ajax instead --- .gitattributes | 6 + examples/ConfigStoreDemo/Pages/_Layout.cshtml | 70 +- .../Pages/_ValidationScriptsPartial.cshtml | 12 +- examples/ConfigStoreDemo/libman.json | 26 + examples/ConfigStoreDemo/wwwroot/css/site.css | 6 +- .../wwwroot/lib/bootstrap/.bower.json | 45 - .../wwwroot/lib/bootstrap/LICENSE | 21 - .../lib/bootstrap/css/bootstrap-grid.css | 4085 ++++++ .../lib/bootstrap/css/bootstrap-grid.css.map | 1 + .../lib/bootstrap/css/bootstrap-grid.min.css | 6 + .../bootstrap/css/bootstrap-grid.min.css.map | 1 + .../lib/bootstrap/css/bootstrap-grid.rtl.css | 4084 ++++++ .../bootstrap/css/bootstrap-grid.rtl.css.map | 1 + .../bootstrap/css/bootstrap-grid.rtl.min.css | 6 + .../css/bootstrap-grid.rtl.min.css.map | 1 + .../lib/bootstrap/css/bootstrap-reboot.css | 597 + .../bootstrap/css/bootstrap-reboot.css.map | 1 + .../bootstrap/css/bootstrap-reboot.min.css | 6 + .../css/bootstrap-reboot.min.css.map | 1 + .../bootstrap/css/bootstrap-reboot.rtl.css | 594 + .../css/bootstrap-reboot.rtl.css.map | 1 + .../css/bootstrap-reboot.rtl.min.css | 6 + .../css/bootstrap-reboot.rtl.min.css.map | 1 + .../lib/bootstrap/css/bootstrap-utilities.css | 5402 +++++++ .../bootstrap/css/bootstrap-utilities.css.map | 1 + .../bootstrap/css/bootstrap-utilities.min.css | 6 + .../css/bootstrap-utilities.min.css.map | 1 + .../bootstrap/css/bootstrap-utilities.rtl.css | 5393 +++++++ .../css/bootstrap-utilities.rtl.css.map | 1 + .../css/bootstrap-utilities.rtl.min.css | 6 + .../css/bootstrap-utilities.rtl.min.css.map | 1 + .../wwwroot/lib/bootstrap/css/bootstrap.css | 12057 ++++++++++++++++ .../lib/bootstrap/css/bootstrap.css.map | 1 + .../lib/bootstrap/css/bootstrap.min.css | 6 + .../lib/bootstrap/css/bootstrap.min.css.map | 1 + .../lib/bootstrap/css/bootstrap.rtl.css | 12030 +++++++++++++++ .../lib/bootstrap/css/bootstrap.rtl.css.map | 1 + .../lib/bootstrap/css/bootstrap.rtl.min.css | 6 + .../bootstrap/css/bootstrap.rtl.min.css.map | 1 + .../bootstrap/dist/css/bootstrap-theme.css | 587 - .../dist/css/bootstrap-theme.css.map | 1 - .../dist/css/bootstrap-theme.min.css.map | 1 - .../lib/bootstrap/dist/css/bootstrap.css | 6757 --------- .../lib/bootstrap/dist/css/bootstrap.css.map | 1 - .../bootstrap/dist/css/bootstrap.min.css.map | 1 - .../fonts/glyphicons-halflings-regular.eot | Bin 20127 -> 0 bytes .../fonts/glyphicons-halflings-regular.svg | 288 - .../fonts/glyphicons-halflings-regular.ttf | Bin 45404 -> 0 bytes .../fonts/glyphicons-halflings-regular.woff | Bin 23424 -> 0 bytes .../fonts/glyphicons-halflings-regular.woff2 | Bin 18028 -> 0 bytes .../lib/bootstrap/dist/js/bootstrap.js | 2377 --- .../wwwroot/lib/bootstrap/dist/js/npm.js | 13 - .../lib/bootstrap/js/bootstrap.bundle.js | 6314 ++++++++ .../lib/bootstrap/js/bootstrap.bundle.js.map | 1 + .../lib/bootstrap/js/bootstrap.bundle.min.js | 7 + .../bootstrap/js/bootstrap.bundle.min.js.map | 1 + .../wwwroot/lib/bootstrap/js/bootstrap.esm.js | 4447 ++++++ .../lib/bootstrap/js/bootstrap.esm.js.map | 1 + .../lib/bootstrap/js/bootstrap.esm.min.js | 7 + .../lib/bootstrap/js/bootstrap.esm.min.js.map | 1 + .../wwwroot/lib/bootstrap/js/bootstrap.js | 4494 ++++++ .../wwwroot/lib/bootstrap/js/bootstrap.js.map | 1 + .../wwwroot/lib/bootstrap/js/bootstrap.min.js | 7 + .../lib/bootstrap/js/bootstrap.min.js.map | 1 + .../lib/bootstrap/scss/_accordion.scss | 158 + .../wwwroot/lib/bootstrap/scss/_alert.scss | 68 + .../wwwroot/lib/bootstrap/scss/_badge.scss | 38 + .../lib/bootstrap/scss/_breadcrumb.scss | 40 + .../lib/bootstrap/scss/_button-group.scss | 142 + .../wwwroot/lib/bootstrap/scss/_buttons.scss | 216 + .../wwwroot/lib/bootstrap/scss/_card.scss | 239 + .../wwwroot/lib/bootstrap/scss/_carousel.scss | 236 + .../wwwroot/lib/bootstrap/scss/_close.scss | 63 + .../lib/bootstrap/scss/_containers.scss | 41 + .../wwwroot/lib/bootstrap/scss/_dropdown.scss | 250 + .../wwwroot/lib/bootstrap/scss/_forms.scss | 9 + .../lib/bootstrap/scss/_functions.scss | 302 + .../wwwroot/lib/bootstrap/scss/_grid.scss | 39 + .../wwwroot/lib/bootstrap/scss/_helpers.scss | 12 + .../wwwroot/lib/bootstrap/scss/_images.scss | 42 + .../lib/bootstrap/scss/_list-group.scss | 197 + .../wwwroot/lib/bootstrap/scss/_maps.scss | 174 + .../wwwroot/lib/bootstrap/scss/_mixins.scss | 42 + .../wwwroot/lib/bootstrap/scss/_modal.scss | 236 + .../wwwroot/lib/bootstrap/scss/_nav.scss | 197 + .../wwwroot/lib/bootstrap/scss/_navbar.scss | 289 + .../lib/bootstrap/scss/_offcanvas.scss | 143 + .../lib/bootstrap/scss/_pagination.scss | 109 + .../lib/bootstrap/scss/_placeholders.scss | 51 + .../wwwroot/lib/bootstrap/scss/_popover.scss | 196 + .../wwwroot/lib/bootstrap/scss/_progress.scss | 68 + .../wwwroot/lib/bootstrap/scss/_reboot.scss | 611 + .../wwwroot/lib/bootstrap/scss/_root.scss | 187 + .../wwwroot/lib/bootstrap/scss/_spinners.scss | 85 + .../wwwroot/lib/bootstrap/scss/_tables.scss | 171 + .../wwwroot/lib/bootstrap/scss/_toasts.scss | 73 + .../wwwroot/lib/bootstrap/scss/_tooltip.scss | 119 + .../lib/bootstrap/scss/_transitions.scss | 27 + .../wwwroot/lib/bootstrap/scss/_type.scss | 106 + .../lib/bootstrap/scss/_utilities.scss | 806 ++ .../lib/bootstrap/scss/_variables-dark.scss | 87 + .../lib/bootstrap/scss/_variables.scss | 1751 +++ .../lib/bootstrap/scss/bootstrap-grid.scss | 62 + .../lib/bootstrap/scss/bootstrap-reboot.scss | 10 + .../bootstrap/scss/bootstrap-utilities.scss | 19 + .../wwwroot/lib/bootstrap/scss/bootstrap.scss | 52 + .../scss/forms/_floating-labels.scss | 95 + .../lib/bootstrap/scss/forms/_form-check.scss | 189 + .../bootstrap/scss/forms/_form-control.scss | 214 + .../lib/bootstrap/scss/forms/_form-range.scss | 91 + .../bootstrap/scss/forms/_form-select.scss | 80 + .../lib/bootstrap/scss/forms/_form-text.scss | 11 + .../bootstrap/scss/forms/_input-group.scss | 132 + .../lib/bootstrap/scss/forms/_labels.scss | 36 + .../lib/bootstrap/scss/forms/_validation.scss | 12 + .../lib/bootstrap/scss/helpers/_clearfix.scss | 3 + .../lib/bootstrap/scss/helpers/_color-bg.scss | 7 + .../scss/helpers/_colored-links.scss | 30 + .../bootstrap/scss/helpers/_focus-ring.scss | 5 + .../bootstrap/scss/helpers/_icon-link.scss | 25 + .../lib/bootstrap/scss/helpers/_position.scss | 36 + .../lib/bootstrap/scss/helpers/_ratio.scss | 26 + .../lib/bootstrap/scss/helpers/_stacks.scss | 15 + .../scss/helpers/_stretched-link.scss | 15 + .../scss/helpers/_text-truncation.scss | 7 + .../scss/helpers/_visually-hidden.scss | 8 + .../lib/bootstrap/scss/helpers/_vr.scss | 8 + .../lib/bootstrap/scss/mixins/_alert.scss | 18 + .../lib/bootstrap/scss/mixins/_backdrop.scss | 14 + .../lib/bootstrap/scss/mixins/_banner.scss | 7 + .../bootstrap/scss/mixins/_border-radius.scss | 78 + .../bootstrap/scss/mixins/_box-shadow.scss | 18 + .../bootstrap/scss/mixins/_breakpoints.scss | 127 + .../lib/bootstrap/scss/mixins/_buttons.scss | 70 + .../lib/bootstrap/scss/mixins/_caret.scss | 69 + .../lib/bootstrap/scss/mixins/_clearfix.scss | 9 + .../bootstrap/scss/mixins/_color-mode.scss | 21 + .../bootstrap/scss/mixins/_color-scheme.scss | 7 + .../lib/bootstrap/scss/mixins/_container.scss | 11 + .../lib/bootstrap/scss/mixins/_deprecate.scss | 10 + .../lib/bootstrap/scss/mixins/_forms.scss | 163 + .../lib/bootstrap/scss/mixins/_gradients.scss | 47 + .../lib/bootstrap/scss/mixins/_grid.scss | 151 + .../lib/bootstrap/scss/mixins/_image.scss | 16 + .../bootstrap/scss/mixins/_list-group.scss | 26 + .../lib/bootstrap/scss/mixins/_lists.scss | 7 + .../bootstrap/scss/mixins/_pagination.scss | 10 + .../bootstrap/scss/mixins/_reset-text.scss | 17 + .../lib/bootstrap/scss/mixins/_resize.scss | 6 + .../scss/mixins/_table-variants.scss | 24 + .../bootstrap/scss/mixins/_text-truncate.scss | 8 + .../bootstrap/scss/mixins/_transition.scss | 26 + .../lib/bootstrap/scss/mixins/_utilities.scss | 97 + .../scss/mixins/_visually-hidden.scss | 33 + .../lib/bootstrap/scss/utilities/_api.scss | 47 + .../lib/bootstrap/scss/vendor/_rfs.scss | 348 + .../jquery-validation-unobtrusive/.bower.json | 86 +- .../jquery.validate.unobtrusive.js | 851 +- .../jquery.validate.unobtrusive.min.js | 13 +- .../wwwroot/lib/jquery-validation/.bower.json | 40 - .../wwwroot/lib/jquery-validation/LICENSE.md | 22 - .../jquery-validation/additional-methods.js | 1505 ++ .../additional-methods.min.js | 4 + .../dist/additional-methods.js | 998 -- .../jquery-validation-sri.json | 1172 ++ .../{dist => }/jquery.validate.js | 921 +- .../jquery-validation/jquery.validate.min.js | 4 + .../localization/messages_ar.js | 64 + .../localization/messages_ar.min.js | 4 + .../localization/messages_az.js | 35 + .../localization/messages_az.min.js | 4 + .../localization/messages_bg.js | 35 + .../localization/messages_bg.min.js | 4 + .../localization/messages_bn_BD.js | 35 + .../localization/messages_bn_BD.min.js | 4 + .../localization/messages_ca.js | 35 + .../localization/messages_ca.min.js | 4 + .../localization/messages_cs.js | 36 + .../localization/messages_cs.min.js | 4 + .../localization/messages_da.js | 46 + .../localization/messages_da.min.js | 4 + .../localization/messages_de.js | 82 + .../localization/messages_de.min.js | 4 + .../localization/messages_el.js | 35 + .../localization/messages_el.min.js | 4 + .../localization/messages_es.js | 38 + .../localization/messages_es.min.js | 4 + .../localization/messages_es_AR.js | 39 + .../localization/messages_es_AR.min.js | 4 + .../localization/messages_es_PE.js | 39 + .../localization/messages_es_PE.min.js | 4 + .../localization/messages_et.js | 33 + .../localization/messages_et.min.js | 4 + .../localization/messages_eu.js | 35 + .../localization/messages_eu.min.js | 4 + .../localization/messages_fa.js | 39 + .../localization/messages_fa.min.js | 4 + .../localization/messages_fi.js | 33 + .../localization/messages_fi.min.js | 4 + .../localization/messages_fr.js | 65 + .../localization/messages_fr.min.js | 4 + .../localization/messages_ge.js | 35 + .../localization/messages_ge.min.js | 4 + .../localization/messages_gl.js | 40 + .../localization/messages_gl.min.js | 4 + .../localization/messages_he.js | 35 + .../localization/messages_he.min.js | 4 + .../localization/messages_hi.js | 54 + .../localization/messages_hi.min.js | 4 + .../localization/messages_hr.js | 35 + .../localization/messages_hr.min.js | 4 + .../localization/messages_hu.js | 35 + .../localization/messages_hu.min.js | 4 + .../localization/messages_hy_AM.js | 35 + .../localization/messages_hy_AM.min.js | 4 + .../localization/messages_id.js | 34 + .../localization/messages_id.min.js | 4 + .../localization/messages_is.js | 33 + .../localization/messages_is.min.js | 4 + .../localization/messages_it.js | 39 + .../localization/messages_it.min.js | 4 + .../localization/messages_ja.js | 36 + .../localization/messages_ja.min.js | 4 + .../localization/messages_ka.js | 35 + .../localization/messages_ka.min.js | 4 + .../localization/messages_kk.js | 35 + .../localization/messages_kk.min.js | 4 + .../localization/messages_ko.js | 35 + .../localization/messages_ko.min.js | 4 + .../localization/messages_lt.js | 35 + .../localization/messages_lt.min.js | 4 + .../localization/messages_lv.js | 35 + .../localization/messages_lv.min.js | 4 + .../localization/messages_mk.js | 35 + .../localization/messages_mk.min.js | 4 + .../localization/messages_my.js | 35 + .../localization/messages_my.min.js | 4 + .../localization/messages_nl.js | 46 + .../localization/messages_nl.min.js | 4 + .../localization/messages_no.js | 35 + .../localization/messages_no.min.js | 4 + .../localization/messages_pl.js | 38 + .../localization/messages_pl.min.js | 4 + .../localization/messages_pt_BR.js | 91 + .../localization/messages_pt_BR.min.js | 4 + .../localization/messages_pt_PT.js | 39 + .../localization/messages_pt_PT.min.js | 4 + .../localization/messages_ro.js | 35 + .../localization/messages_ro.min.js | 4 + .../localization/messages_ru.js | 35 + .../localization/messages_ru.min.js | 4 + .../localization/messages_sd.js | 35 + .../localization/messages_sd.min.js | 4 + .../localization/messages_si.js | 35 + .../localization/messages_si.min.js | 4 + .../localization/messages_sk.js | 33 + .../localization/messages_sk.min.js | 4 + .../localization/messages_sl.js | 35 + .../localization/messages_sl.min.js | 4 + .../localization/messages_sr.js | 36 + .../localization/messages_sr.min.js | 4 + .../localization/messages_sr_lat.js | 36 + .../localization/messages_sr_lat.min.js | 4 + .../localization/messages_sv.js | 35 + .../localization/messages_sv.min.js | 4 + .../localization/messages_th.js | 35 + .../localization/messages_th.min.js | 4 + .../localization/messages_tj.js | 35 + .../localization/messages_tj.min.js | 4 + .../localization/messages_tr.js | 37 + .../localization/messages_tr.min.js | 4 + .../localization/messages_uk.js | 35 + .../localization/messages_uk.min.js | 4 + .../localization/messages_ur.js | 35 + .../localization/messages_ur.min.js | 4 + .../localization/messages_vi.js | 35 + .../localization/messages_vi.min.js | 4 + .../localization/messages_zh.js | 36 + .../localization/messages_zh.min.js | 4 + .../localization/messages_zh_TW.js | 37 + .../localization/messages_zh_TW.min.js | 4 + .../localization/methods_de.js | 24 + .../localization/methods_de.min.js | 4 + .../localization/methods_es_CL.js | 24 + .../localization/methods_es_CL.min.js | 4 + .../localization/methods_fi.js | 24 + .../localization/methods_fi.min.js | 4 + .../localization/methods_it.js | 24 + .../localization/methods_it.min.js | 4 + .../localization/methods_nl.js | 24 + .../localization/methods_nl.min.js | 4 + .../localization/methods_pt.js | 21 + .../localization/methods_pt.min.js | 4 + .../wwwroot/lib/jquery/.bower.json | 25 - .../wwwroot/lib/jquery/LICENSE.txt | 36 - .../wwwroot/lib/jquery/dist/jquery.min.map | 1 - .../wwwroot/lib/jquery/{dist => }/jquery.js | 6411 ++++---- .../wwwroot/lib/jquery/jquery.min.js | 2 + .../wwwroot/lib/jquery/jquery.min.map | 1 + .../wwwroot/lib/jquery/jquery.slim.js | 8617 +++++++++++ .../wwwroot/lib/jquery/jquery.slim.min.js | 2 + .../wwwroot/lib/jquery/jquery.slim.min.map | 1 + 302 files changed, 88634 insertions(+), 14793 deletions(-) create mode 100644 examples/ConfigStoreDemo/libman.json delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/.bower.json delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/LICENSE create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.rtl.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.rtl.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.rtl.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.rtl.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.rtl.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.rtl.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.rtl.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.rtl.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.rtl.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.rtl.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.rtl.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.rtl.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.rtl.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.rtl.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.rtl.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.rtl.min.css.map delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap-theme.css delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap-theme.css.map delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap-theme.min.css.map delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap.css delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.eot delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.svg delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.woff delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2 delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/js/bootstrap.js delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/js/npm.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.bundle.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.bundle.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.esm.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.esm.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.esm.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.esm.min.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.min.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_accordion.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_alert.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_badge.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_breadcrumb.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_button-group.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_buttons.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_card.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_carousel.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_close.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_containers.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_dropdown.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_forms.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_functions.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_grid.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_helpers.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_images.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_list-group.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_maps.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_mixins.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_modal.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_nav.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_navbar.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_offcanvas.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_pagination.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_placeholders.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_popover.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_progress.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_reboot.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_root.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_spinners.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_tables.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_toasts.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_tooltip.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_transitions.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_type.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_utilities.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_variables-dark.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_variables.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/bootstrap-grid.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/bootstrap-reboot.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/bootstrap-utilities.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/bootstrap.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_floating-labels.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_form-check.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_form-control.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_form-range.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_form-select.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_form-text.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_input-group.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_labels.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_validation.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_clearfix.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_color-bg.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_colored-links.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_focus-ring.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_icon-link.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_position.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_ratio.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_stacks.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_stretched-link.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_text-truncation.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_visually-hidden.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_vr.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_alert.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_backdrop.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_banner.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_border-radius.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_box-shadow.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_breakpoints.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_buttons.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_caret.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_clearfix.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_color-mode.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_color-scheme.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_container.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_deprecate.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_forms.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_gradients.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_grid.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_image.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_list-group.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_lists.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_pagination.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_reset-text.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_resize.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_table-variants.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_text-truncate.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_transition.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_utilities.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_visually-hidden.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/utilities/_api.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/vendor/_rfs.scss delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/.bower.json delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/LICENSE.md create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/additional-methods.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/additional-methods.min.js delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/dist/additional-methods.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/jquery-validation-sri.json rename examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/{dist => }/jquery.validate.js (56%) create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/jquery.validate.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ar.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ar.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_az.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_az.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_bg.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_bg.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_bn_BD.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_bn_BD.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ca.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ca.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_cs.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_cs.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_da.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_da.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_de.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_de.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_el.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_el.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es_AR.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es_AR.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es_PE.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es_PE.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_et.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_et.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_eu.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_eu.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fa.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fa.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fi.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fi.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fr.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fr.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ge.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ge.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_gl.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_gl.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_he.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_he.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hi.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hi.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hr.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hr.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hu.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hu.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hy_AM.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hy_AM.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_id.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_id.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_is.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_is.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_it.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_it.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ja.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ja.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ka.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ka.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_kk.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_kk.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ko.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ko.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_lt.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_lt.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_lv.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_lv.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_mk.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_mk.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_my.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_my.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_nl.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_nl.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_no.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_no.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pl.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pl.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pt_BR.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pt_BR.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pt_PT.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pt_PT.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ro.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ro.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ru.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ru.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sd.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sd.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_si.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_si.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sk.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sk.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sl.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sl.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sr.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sr.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sr_lat.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sr_lat.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sv.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sv.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_th.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_th.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_tj.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_tj.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_tr.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_tr.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_uk.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_uk.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ur.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ur.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_vi.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_vi.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_zh.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_zh.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_zh_TW.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_zh_TW.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_de.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_de.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_es_CL.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_es_CL.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_fi.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_fi.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_it.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_it.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_nl.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_nl.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_pt.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_pt.min.js delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/.bower.json delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/LICENSE.txt delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/dist/jquery.min.map rename examples/ConfigStoreDemo/wwwroot/lib/jquery/{dist => }/jquery.js (60%) create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/jquery.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/jquery.min.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/jquery.slim.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/jquery.slim.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/jquery.slim.min.map diff --git a/.gitattributes b/.gitattributes index 538c95f5..5cfb8610 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,9 @@ # If there are abnormal line endings in any file, run "git add --renormalize ", # review the changes, and commit them to fix the line endings. * text=auto + +# Third-party library files should not have line endings normalized +**/wwwroot/lib/** -text +*.min.css -text +*.min.js -text +*.map -text diff --git a/examples/ConfigStoreDemo/Pages/_Layout.cshtml b/examples/ConfigStoreDemo/Pages/_Layout.cshtml index 553e6de6..5da3134b 100644 --- a/examples/ConfigStoreDemo/Pages/_Layout.cshtml +++ b/examples/ConfigStoreDemo/Pages/_Layout.cshtml @@ -3,15 +3,16 @@ - @ViewData["Title"] - Azure App Configuration Demo - + @ViewData["Title"] - Azure App Configuration Demo - - + + - + -