diff --git a/examples/ConfigStoreDemo/Program.cs b/examples/ConfigStoreDemo/Program.cs index 2614c7465..40d0b9edc 100644 --- a/examples/ConfigStoreDemo/Program.cs +++ b/examples/ConfigStoreDemo/Program.cs @@ -24,14 +24,6 @@ public static IWebHost BuildWebHost(string[] args) // 3. Set up the provider to listen for changes to the background color key-value in Azure App Configuration var settings = config.AddJsonFile("appsettings.json").Build(); - - if (string.IsNullOrEmpty(settings["connection_string"])) - { - throw new InvalidOperationException( - "Connection string not found. " + - "Please set the 'connection_string' in appsettings.json."); - } - config.AddAzureAppConfiguration(options => { options.Connect(settings["connection_string"]) diff --git a/examples/ConsoleAppWithFailOver/Program.cs b/examples/ConsoleAppWithFailOver/Program.cs index dd89670d5..decbba29e 100644 --- a/examples/ConsoleAppWithFailOver/Program.cs +++ b/examples/ConsoleAppWithFailOver/Program.cs @@ -31,10 +31,7 @@ private static void Configure() IConfiguration configuration = builder.Build(); IConfigurationSection endpointsSection = configuration.GetSection("AppConfig:Endpoints"); - IEnumerable endpoints = endpointsSection.GetChildren() - .Select(endpoint => endpoint.Value) - .Where(value => !string.IsNullOrEmpty(value)) - .Select(value => new Uri(value)); + IEnumerable endpoints = endpointsSection.GetChildren().Select(endpoint => new Uri(endpoint.Value)); if (endpoints == null || !endpoints.Any()) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdClientManager.cs new file mode 100644 index 000000000..7668d9af3 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdClientManager.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Azure; +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd +{ + internal class AfdClientManager : IAppConfigurationClientManager + { + private readonly AppConfigurationClient _clientWrapper; + + public AfdClientManager( + IAzureClientFactory configurationClientFactory, + IAzureClientFactory featureFlagClientFactory, + Uri endpoint) + { + if (configurationClientFactory == null) + { + throw new ArgumentNullException(nameof(configurationClientFactory)); + } + + if (featureFlagClientFactory == null) + { + throw new ArgumentNullException(nameof(featureFlagClientFactory)); + } + + if (endpoint == null) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + _clientWrapper = new AppConfigurationClient( + endpoint, + configurationClientFactory.CreateClient(endpoint.AbsoluteUri), + featureFlagClientFactory.CreateClient(endpoint.AbsoluteUri)); + } + + public IEnumerable GetClients() + { + return new List { _clientWrapper }; + } + + public void RefreshClients() + { + return; + } + + public bool UpdateSyncToken(Uri endpoint, string syncToken) + { + return false; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs deleted file mode 100644 index fbf057c1b..000000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Azure; -using System; -using System.Collections.Generic; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd -{ - internal class AfdConfigurationClientManager : IConfigurationClientManager - { - private readonly ConfigurationClientWrapper _clientWrapper; - - public AfdConfigurationClientManager( - IAzureClientFactory clientFactory, - Uri endpoint) - { - if (clientFactory == null) - { - throw new ArgumentNullException(nameof(clientFactory)); - } - - if (endpoint == null) - { - throw new ArgumentNullException(nameof(endpoint)); - } - - _clientWrapper = new ConfigurationClientWrapper(endpoint, clientFactory.CreateClient(endpoint.AbsoluteUri)); - } - - public IEnumerable GetClients() - { - return new List { _clientWrapper.Client }; - } - - public void RefreshClients() - { - return; - } - - public bool UpdateSyncToken(Uri endpoint, string syncToken) - { - return false; - } - - public Uri GetEndpointForClient(ConfigurationClient client) - { - if (client == null) - { - throw new ArgumentNullException(nameof(client)); - } - - return _clientWrapper.Client == client ? _clientWrapper.Endpoint : null; - } - } -} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AppConfigurationClient.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AppConfigurationClient.cs new file mode 100644 index 000000000..b6db06655 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AppConfigurationClient.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Data.AppConfiguration; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + /// + /// The default implementation. It holds a + /// for key-values (including feature flags) and a + /// for enhanced feature flags served by the dedicated feature-flag endpoint, + /// both targeting the same . + /// + internal class AppConfigurationClient : IAppConfigurationClient + { + private readonly ConfigurationClient _configurationClient; + private readonly FeatureFlagClient _featureFlagClient; + + public AppConfigurationClient(Uri endpoint, ConfigurationClient configurationClient, FeatureFlagClient featureFlagClient) + { + Endpoint = endpoint; + _configurationClient = configurationClient ?? throw new ArgumentNullException(nameof(configurationClient)); + _featureFlagClient = featureFlagClient ?? throw new ArgumentNullException(nameof(featureFlagClient)); + } + + public Uri Endpoint { get; } + + public AsyncPageable GetConfigurationSettingsAsync(SettingSelector selector, CancellationToken cancellationToken) + { + return _configurationClient.GetConfigurationSettingsAsync(selector, cancellationToken); + } + + public AsyncPageable CheckConfigurationSettingsAsync(SettingSelector selector, CancellationToken cancellationToken) + { + return _configurationClient.CheckConfigurationSettingsAsync(selector, cancellationToken); + } + + public Task> GetConfigurationSettingAsync(string key, string label, CancellationToken cancellationToken) + { + return _configurationClient.GetConfigurationSettingAsync(key, label, cancellationToken); + } + + public Task> GetConfigurationSettingAsync(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) + { + return _configurationClient.GetConfigurationSettingAsync(setting, onlyIfChanged, cancellationToken); + } + + public Task> GetSnapshotAsync(string snapshotName, CancellationToken cancellationToken) + { + return _configurationClient.GetSnapshotAsync(snapshotName, cancellationToken: cancellationToken); + } + + public AsyncPageable GetConfigurationSettingsForSnapshotAsync(string snapshotName, CancellationToken cancellationToken) + { + return _configurationClient.GetConfigurationSettingsForSnapshotAsync(snapshotName, cancellationToken); + } + + public AsyncPageable GetFeatureFlagsAsync(FeatureFlagSelector selector, CancellationToken cancellationToken) + { + return _featureFlagClient.GetFeatureFlagsAsync(selector, cancellationToken); + } + + public void UpdateSyncToken(string syncToken) + { + _configurationClient.UpdateSyncToken(syncToken); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AppConfigurationClientManager.cs similarity index 79% rename from src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs rename to src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AppConfigurationClientManager.cs index 61840d036..b6630334d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AppConfigurationClientManager.cs @@ -24,10 +24,11 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration /// This class is not thread-safe. Since config provider does not allow multiple network requests at the same time, /// there won't be multiple threads calling this client at the same time. /// - internal class ConfigurationClientManager : IConfigurationClientManager, IDisposable + internal class AppConfigurationClientManager : IAppConfigurationClientManager, IDisposable { - private readonly IAzureClientFactory _clientFactory; - private readonly IList _clients; + private readonly IAzureClientFactory _configurationClientFactory; + private readonly IAzureClientFactory _featureFlagClientFactory; + private readonly IList _clients; private readonly Uri _endpoint; @@ -35,7 +36,7 @@ internal class ConfigurationClientManager : IConfigurationClientManager, IDispos private readonly SrvLookupClient _srvLookupClient; private readonly string _validDomain; - private IList _dynamicClients; + private IList _dynamicClients; private DateTimeOffset _lastFallbackClientRefresh = default; private DateTimeOffset _lastFallbackClientRefreshAttempt = default; private Logger _logger = new Logger(); @@ -50,13 +51,15 @@ internal class ConfigurationClientManager : IConfigurationClientManager, IDispos // Only used for unit testing internal int RefreshClientsCalled { get; set; } = 0; - public ConfigurationClientManager( - IAzureClientFactory clientFactory, + public AppConfigurationClientManager( + IAzureClientFactory configurationClientFactory, + IAzureClientFactory featureFlagClientFactory, IEnumerable endpoints, bool replicaDiscoveryEnabled, bool loadBalancingEnabled) { - _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + _configurationClientFactory = configurationClientFactory ?? throw new ArgumentNullException(nameof(configurationClientFactory)); + _featureFlagClientFactory = featureFlagClientFactory ?? throw new ArgumentNullException(nameof(featureFlagClientFactory)); if (endpoints == null || !endpoints.Any()) { @@ -77,7 +80,10 @@ public ConfigurationClientManager( _srvLookupClient = new SrvLookupClient(); _clients = endpoints - .Select(endpoint => new ConfigurationClientWrapper(endpoint, clientFactory.CreateClient(endpoint.AbsoluteUri))) + .Select(endpoint => new AppConfigurationClient( + endpoint, + configurationClientFactory.CreateClient(endpoint.AbsoluteUri), + featureFlagClientFactory.CreateClient(endpoint.AbsoluteUri))) .ToList(); } @@ -85,12 +91,12 @@ public ConfigurationClientManager( /// Internal constructor; Only used for unit testing. /// /// - internal ConfigurationClientManager(IList clients) + internal AppConfigurationClientManager(IList clients) { _clients = clients; } - public IEnumerable GetClients() + public IEnumerable GetClients() { DateTimeOffset now = DateTimeOffset.UtcNow; @@ -105,11 +111,11 @@ public IEnumerable GetClients() } // Treat the passed in endpoints as the highest priority clients - IEnumerable clients = _clients.Select(c => c.Client); + IEnumerable clients = _clients; if (_dynamicClients != null && _dynamicClients.Any()) { - clients = clients.Concat(_dynamicClients.Select(c => c.Client)); + clients = clients.Concat(_dynamicClients); } return clients; @@ -142,39 +148,22 @@ public bool UpdateSyncToken(Uri endpoint, string syncToken) throw new ArgumentNullException(nameof(syncToken)); } - ConfigurationClientWrapper clientWrapper = _clients.SingleOrDefault(c => new EndpointComparer().Equals(c.Endpoint, endpoint)); + AppConfigurationClient client = _clients.SingleOrDefault(c => new EndpointComparer().Equals(c.Endpoint, endpoint)); - if (_dynamicClients != null && clientWrapper == null) + if (_dynamicClients != null && client == null) { - clientWrapper = _dynamicClients.SingleOrDefault(c => new EndpointComparer().Equals(c.Endpoint, endpoint)); + client = _dynamicClients.SingleOrDefault(c => new EndpointComparer().Equals(c.Endpoint, endpoint)); } - if (clientWrapper != null) + if (client != null) { - clientWrapper.Client.UpdateSyncToken(syncToken); + client.UpdateSyncToken(syncToken); return true; } return false; } - public Uri GetEndpointForClient(ConfigurationClient client) - { - if (client == null) - { - throw new ArgumentNullException(nameof(client)); - } - - ConfigurationClientWrapper currentClient = _clients.FirstOrDefault(c => c.Client == client); - - if (_dynamicClients != null && currentClient == null) - { - currentClient = _dynamicClients.FirstOrDefault(c => c.Client == client); - } - - return currentClient?.Endpoint; - } - public void SetLogger(Logger logger) { if (logger == null) @@ -231,7 +220,7 @@ private async Task RefreshFallbackClients(CancellationToken cancellationToken) return; } - var newDynamicClients = new List(); + var newDynamicClients = new List(); // Honor with the DNS based service discovery protocol, but shuffle the results first to ensure hosts can be picked randomly, // Srv lookup does retrieve trailing dot in the host name, just trim it. @@ -247,9 +236,11 @@ private async Task RefreshFallbackClients(CancellationToken cancellationToken) { var targetEndpoint = new Uri($"https://{host}"); - ConfigurationClient configClient = _clientFactory.CreateClient(targetEndpoint.AbsoluteUri); + ConfigurationClient configClient = _configurationClientFactory.CreateClient(targetEndpoint.AbsoluteUri); + + FeatureFlagClient featureFlagClient = _featureFlagClientFactory.CreateClient(targetEndpoint.AbsoluteUri); - newDynamicClients.Add(new ConfigurationClientWrapper(targetEndpoint, configClient)); + newDynamicClients.Add(new AppConfigurationClient(targetEndpoint, configClient, featureFlagClient)); } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationFeatureFlagClientFactory.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationFeatureFlagClientFactory.cs new file mode 100644 index 000000000..5f5ca7570 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationFeatureFlagClientFactory.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Core; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Azure; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class AzureAppConfigurationFeatureFlagClientFactory : IAzureClientFactory + { + private readonly FeatureFlagClientOptions _clientOptions; + + private readonly TokenCredential _credential; + private readonly IEnumerable _connectionStrings; + + public AzureAppConfigurationFeatureFlagClientFactory( + IEnumerable connectionStrings, + FeatureFlagClientOptions clientOptions) + { + if (connectionStrings == null || !connectionStrings.Any()) + { + throw new ArgumentNullException(nameof(connectionStrings)); + } + + _connectionStrings = connectionStrings; + + _clientOptions = clientOptions ?? throw new ArgumentNullException(nameof(clientOptions)); + } + + public AzureAppConfigurationFeatureFlagClientFactory( + TokenCredential credential, + FeatureFlagClientOptions clientOptions) + { + _credential = credential ?? throw new ArgumentNullException(nameof(credential)); + _clientOptions = clientOptions ?? throw new ArgumentNullException(nameof(clientOptions)); + } + + public FeatureFlagClient CreateClient(string endpoint) + { + if (string.IsNullOrEmpty(endpoint)) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri uriResult)) + { + throw new ArgumentException("Invalid host URI."); + } + + if (_credential != null) + { + return new FeatureFlagClient(uriResult, _credential, _clientOptions); + } + + string connectionString = _connectionStrings.FirstOrDefault(cs => ConnectionStringUtils.Parse(cs, ConnectionStringUtils.EndpointSection) == endpoint); + + // + // fallback to the first connection string + if (connectionString == null) + { + string id = ConnectionStringUtils.Parse(_connectionStrings.First(), ConnectionStringUtils.IdSection); + string secret = ConnectionStringUtils.Parse(_connectionStrings.First(), ConnectionStringUtils.SecretSection); + + connectionString = ConnectionStringUtils.Build(uriResult, id, secret); + } + + return new FeatureFlagClient(connectionString, _clientOptions); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 52fdcece7..9f8201f6e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; +using FeatureFlagSelector = Microsoft.Extensions.Configuration.AzureAppConfiguration.Models.FeatureFlagSelector; using System; using System.Collections.Generic; using System.Linq; @@ -29,9 +30,10 @@ public class AzureAppConfigurationOptions private List _individualKvWatchers = new List(); private List _ffWatchers = new List(); + private List _ffSelectors = new List(); private List _adapters; private List>> _mappers = new List>>(); - private List _selectors; + private List _kvSelectors; private IConfigurationRefresher _refresher = new AzureAppConfigurationRefresher(); private bool _selectCalled = false; @@ -69,7 +71,7 @@ public class AzureAppConfigurationOptions /// /// A collection of specified by user. /// - internal IEnumerable Selectors => _selectors; + internal IEnumerable KeyValueSelectors => _kvSelectors; /// /// Indicates if was called. @@ -91,6 +93,11 @@ public class AzureAppConfigurationOptions /// internal IEnumerable FeatureFlagWatchers => _ffWatchers; + /// + /// A collection of used to select feature flags. + /// + internal IEnumerable FeatureFlagSelectors => _ffSelectors; + /// /// A collection of . /// @@ -111,15 +118,20 @@ internal IEnumerable Adapters 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. + /// For use in tests only. An optional client manager that can be used to provide clients to communicate with Azure App Configuration. /// - internal IConfigurationClientManager ClientManager { get; set; } + internal IAppConfigurationClientManager 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; } + /// + /// For use in tests only. An optional class used to process pageable feature flag results from the standalone feature-flag endpoint. + /// + internal IFeatureFlagPageIterator FeatureFlagPageIterator { get; set; } + /// /// For use in tests only. An optional activity source name to specify the activity source used by the configuration provider. /// @@ -135,6 +147,11 @@ internal IEnumerable Adapters /// internal ConfigurationClientOptions ClientOptions { get; private set; } = GetDefaultClientOptions(); + /// + /// Options used to configure the client used to communicate with the Azure App Configuration feature-flag endpoint. + /// + internal FeatureFlagClientOptions FeatureFlagClientOptions { get; private set; } = GetDefaultFeatureFlagClientOptions(); + /// /// Flag to indicate whether Key Vault options have been configured. /// @@ -173,12 +190,11 @@ public AzureAppConfigurationOptions() _adapters = new List() { new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()), - new JsonKeyValueAdapter(), - new FeatureManagementKeyValueAdapter(FeatureFlagTracing) + new JsonKeyValueAdapter() }; // Adds the default query to App Configuration if and are never called. - _selectors = new List { DefaultQuery }; + _kvSelectors = new List { DefaultQuery }; } /// @@ -253,12 +269,12 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter if (!_selectCalled) { - _selectors.Remove(DefaultQuery); + _kvSelectors.Remove(DefaultQuery); _selectCalled = true; } - _selectors.AppendUnique(new KeyValueSelector + _kvSelectors.AppendUnique(new KeyValueSelector { KeyFilter = keyFilter, LabelFilter = labelFilter, @@ -282,12 +298,12 @@ public AzureAppConfigurationOptions SelectSnapshot(string name) if (!_selectCalled) { - _selectors.Remove(DefaultQuery); + _kvSelectors.Remove(DefaultQuery); _selectCalled = true; } - _selectors.AppendUnique(new KeyValueSelector + _kvSelectors.AppendUnique(new KeyValueSelector { SnapshotName = name }); @@ -320,24 +336,23 @@ public AzureAppConfigurationOptions UseFeatureFlags(Action c if (options.FeatureFlagSelectors.Count() == 0) { // Select clause is not present - options.FeatureFlagSelectors.Add(new KeyValueSelector + options.FeatureFlagSelectors.Add(new FeatureFlagSelector { - KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", - LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, - IsFeatureFlagSelector = true + NameFilter = KeyFilter.Any, + LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label }); } - foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) + foreach (FeatureFlagSelector featureFlagSelector in options.FeatureFlagSelectors) { - _selectors.AppendUnique(featureFlagSelector); + _ffSelectors.AppendUnique(featureFlagSelector); _ffWatchers.AppendUnique(new KeyValueWatcher { - Key = featureFlagSelector.KeyFilter, + Key = featureFlagSelector.NameFilter, Label = featureFlagSelector.LabelFilter, Tags = featureFlagSelector.TagFilters, - // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins + // If UseFeatureFlags is called multiple times for the same name and label filters, last refresh interval wins RefreshInterval = options.RefreshInterval }); } @@ -489,6 +504,20 @@ public AzureAppConfigurationOptions TrimKeyPrefix(string prefix) public AzureAppConfigurationOptions ConfigureClientOptions(Action configure) { configure?.Invoke(ClientOptions); + + // Reflect the relevant settings onto the feature-flag client options so that both clients + // communicate with the same store, audience, transport and retry behavior. + FeatureFlagClientOptions.Retry.MaxRetries = ClientOptions.Retry.MaxRetries; + FeatureFlagClientOptions.Retry.MaxDelay = ClientOptions.Retry.MaxDelay; + FeatureFlagClientOptions.Retry.Mode = ClientOptions.Retry.Mode; + FeatureFlagClientOptions.Retry.NetworkTimeout = ClientOptions.Retry.NetworkTimeout; + FeatureFlagClientOptions.Audience = ClientOptions.Audience; + + if (ClientOptions.Transport != null) + { + FeatureFlagClientOptions.Transport = ClientOptions.Transport; + } + return this; } @@ -596,7 +625,7 @@ public AzureAppConfigurationOptions ConfigureStartupOptions(Action _mappedData; private Dictionary _watchedIndividualKvs = new Dictionary(); private HashSet _ffKeys = new HashSet(); + private IEnumerable _enhancedFeatureFlags = Enumerable.Empty(); private Dictionary> _watchedKvPages = new Dictionary>(); - private Dictionary> _watchedFfPages = new Dictionary>(); + private Dictionary> _watchedFeatureFlagPages = new Dictionary>(); + private Dictionary> _watchedEnhancedFeatureFlagPages = new Dictionary>(); private RequestTracingOptions _requestTracingOptions; - private Dictionary _configClientBackoffs = new Dictionary(); + private Dictionary _clientBackoffs = new Dictionary(); private DateTimeOffset _nextCollectionRefreshTime; private readonly TimeSpan MinRefreshInterval; @@ -58,12 +66,24 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private DateTimeOffset? _lastSuccessfulAttempt = null; private DateTimeOffset? _lastFailedAttempt = null; - private class ConfigurationClientBackoffStatus + private class ClientBackoffStatus { public int FailedAttempts { get; set; } public DateTimeOffset BackoffEndTime { get; set; } } + private class EnhancedFeatureFlagLoadResult + { + public IEnumerable EnhancedFeatureFlags { get; set; } + public Dictionary> Pages { get; set; } + } + + private class FeatureFlagLoadResult + { + public IEnumerable FeatureFlags { get; set; } + public Dictionary> Pages { get; set; } + } + public Uri AppConfigurationEndpoint { get @@ -103,7 +123,7 @@ public ILoggerFactory LoggerFactory { _logger = new Logger(_loggerFactory.CreateLogger(LoggingConstants.AppConfigRefreshLogCategory)); - if (_configClientManager is ConfigurationClientManager clientManager) + if (_clientManager is AppConfigurationClientManager clientManager) { clientManager.SetLogger(_logger); } @@ -111,9 +131,9 @@ public ILoggerFactory LoggerFactory } } - public AzureAppConfigurationProvider(IConfigurationClientManager configClientManager, AzureAppConfigurationOptions options, bool optional) + public AzureAppConfigurationProvider(IAppConfigurationClientManager clientManager, AzureAppConfigurationOptions options, bool optional) { - _configClientManager = configClientManager ?? throw new ArgumentNullException(nameof(configClientManager)); + _clientManager = clientManager ?? throw new ArgumentNullException(nameof(clientManager)); _options = options ?? throw new ArgumentNullException(nameof(options)); _optional = optional; @@ -144,6 +164,8 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan _requestTracingEnabled = !EnvironmentVariableHelper.GetBoolOrDefault(EnvironmentVariableNames.RequestTracingDisabled); + _fmSchemaCompatibilityDisabled = EnvironmentVariableHelper.GetBoolOrDefault(EnvironmentVariableNames.FmSchemacompatibilityDisabled); + if (_requestTracingEnabled) { SetRequestTracingOptions(); @@ -225,7 +247,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) return; } - IEnumerable clients = _configClientManager.GetClients(); + IEnumerable clients = _clientManager.GetClients(); if (_requestTracingOptions != null) { @@ -236,13 +258,13 @@ public async Task RefreshAsync(CancellationToken cancellationToken) // Filter clients based on their backoff status clients = clients.Where(client => { - Uri endpoint = _configClientManager.GetEndpointForClient(client); + Uri endpoint = client.Endpoint; - if (!_configClientBackoffs.TryGetValue(endpoint, out ConfigurationClientBackoffStatus clientBackoffStatus)) + if (!_clientBackoffs.TryGetValue(endpoint, out ClientBackoffStatus clientBackoffStatus)) { - clientBackoffStatus = new ConfigurationClientBackoffStatus(); + clientBackoffStatus = new ClientBackoffStatus(); - _configClientBackoffs[endpoint] = clientBackoffStatus; + _clientBackoffs[endpoint] = clientBackoffStatus; } return clientBackoffStatus.BackoffEndTime <= utcNow; @@ -251,7 +273,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) if (!clients.Any()) { - _configClientManager.RefreshClients(); + _clientManager.RefreshClients(); _logger.LogDebug(LogHelper.BuildRefreshSkippedNoClientAvailableMessage()); @@ -277,47 +299,43 @@ public async Task RefreshAsync(CancellationToken cancellationToken) // // Avoid instance state modification Dictionary> kvEtags = null; - Dictionary> ffEtags = null; - HashSet ffKeys = null; Dictionary watchedIndividualKvs = null; List watchedIndividualKvChanges = null; Dictionary data = null; - Dictionary ffCollectionData = null; + FeatureFlagLoadResult featureFlagLoadResult = null; + EnhancedFeatureFlagLoadResult enhancedFeatureFlagLoadResult = null; bool refreshFeatureFlag = false; bool refreshAll = false; StringBuilder logInfoBuilder = new StringBuilder(); StringBuilder logDebugBuilder = new StringBuilder(); - await ExecuteWithFailOverPolicyAsync(clients, async (client) => + await ExecuteWithFailOverPolicyAsync(clients, async (appConfigClient) => { kvEtags = null; - ffEtags = null; - ffKeys = null; watchedIndividualKvs = null; watchedIndividualKvChanges = new List(); data = null; - ffCollectionData = null; refreshFeatureFlag = false; refreshAll = false; logDebugBuilder.Clear(); logInfoBuilder.Clear(); - Uri endpoint = _configClientManager.GetEndpointForClient(client); + Uri endpoint = appConfigClient.Endpoint; if (_options.RegisterAllEnabled) { if (isRefreshDue) { refreshAll = await HaveCollectionsChanged( - _options.Selectors.Where(selector => !selector.IsFeatureFlagSelector), + _options.KeyValueSelectors, _watchedKvPages, - client, + appConfigClient, cancellationToken).ConfigureAwait(false); } } else { refreshAll = await RefreshIndividualKvWatchers( - client, + appConfigClient, watchedIndividualKvChanges, refreshableIndividualKvWatchers, endpoint, @@ -331,12 +349,20 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => // Trigger a single load-all operation if a change was detected in one or more key-values with refreshAll: true, // or if any key-value collection change was detected. kvEtags = new Dictionary>(); - ffEtags = new Dictionary>(); - ffKeys = new HashSet(); - data = await LoadSelected(client, kvEtags, ffEtags, _options.Selectors, ffKeys, cancellationToken).ConfigureAwait(false); + data = await LoadKeyValues(appConfigClient, kvEtags, _options.KeyValueSelectors, cancellationToken).ConfigureAwait(false); + + featureFlagLoadResult = await LoadFeatureFlags( + appConfigClient, + _options.FeatureFlagSelectors, + cancellationToken).ConfigureAwait(false); + + enhancedFeatureFlagLoadResult = await LoadEnhancedFeatureFlags( + appConfigClient, + _options.FeatureFlagSelectors, + cancellationToken).ConfigureAwait(false); - watchedIndividualKvs = await LoadIndividualWatchedSettings(client, data, cancellationToken).ConfigureAwait(false); + watchedIndividualKvs = await LoadIndividualWatchedSettings(appConfigClient, data, cancellationToken).ConfigureAwait(false); logInfoBuilder.AppendLine(LogHelper.BuildConfigurationUpdatedMessage()); @@ -344,29 +370,19 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => } // Get feature flag changes - refreshFeatureFlag = await HaveCollectionsChanged( - refreshableFfWatchers.Select(watcher => new KeyValueSelector - { - KeyFilter = watcher.Key, - LabelFilter = watcher.Label, - TagFilters = watcher.Tags, - IsFeatureFlagSelector = true - }), - _watchedFfPages, - client, - cancellationToken).ConfigureAwait(false); + refreshFeatureFlag = await HaveFeatureFlagsChanged(_options.FeatureFlagSelectors, _watchedFeatureFlagPages, appConfigClient, cancellationToken).ConfigureAwait(false) + || await HaveEnhancedFeatureFlagsChanged(_options.FeatureFlagSelectors, _watchedEnhancedFeatureFlagPages, appConfigClient, cancellationToken).ConfigureAwait(false); if (refreshFeatureFlag) { - ffEtags = new Dictionary>(); - ffKeys = new HashSet(); - - ffCollectionData = await LoadSelected( - client, - new Dictionary>(), - ffEtags, - _options.Selectors.Where(selector => selector.IsFeatureFlagSelector), - ffKeys, + featureFlagLoadResult = await LoadFeatureFlags( + appConfigClient, + _options.FeatureFlagSelectors, + cancellationToken).ConfigureAwait(false); + + enhancedFeatureFlagLoadResult = await LoadEnhancedFeatureFlags( + appConfigClient, + _options.FeatureFlagSelectors, cancellationToken).ConfigureAwait(false); logInfoBuilder.Append(LogHelper.BuildFeatureFlagsUpdatedMessage()); @@ -379,9 +395,27 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => cancellationToken) .ConfigureAwait(false); + // Derive the set of feature flag keys from the load result. + HashSet ffKeys = featureFlagLoadResult != null + ? new HashSet(featureFlagLoadResult.FeatureFlags.Select(ff => ff.Key)) + : null; + if (refreshAll) { + // Exclude any feature flags that are superseded by an enhanced feature flag with the same name. + var ineligibleFfKeys = new HashSet( + enhancedFeatureFlagLoadResult.EnhancedFeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name)); + + IEnumerable eligibleFeatureFlags = featureFlagLoadResult.FeatureFlags + .Where(setting => !ineligibleFfKeys.Contains(setting.Key)); + + foreach (ConfigurationSetting setting in eligibleFeatureFlags) + { + data[setting.Key] = setting; + } + _mappedData = await MapConfigurationSettings(data).ConfigureAwait(false); + _enhancedFeatureFlags = enhancedFeatureFlagLoadResult.EnhancedFeatureFlags; // Invalidate all the cached KeyVault secrets foreach (IKeyValueAdapter adapter in _options.Adapters) @@ -403,18 +437,27 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => if (refreshFeatureFlag) { - // Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously - foreach (string key in _ffKeys.Except(ffKeys)) + // Remove all previously-loaded feature flags. The current eligible set is added back below. + foreach (string key in _ffKeys) { _mappedData.Remove(key); } - Dictionary mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false); + // Exclude any feature flags that are superseded by an enhanced feature flag with the same name. + var ineligibleFfKeys = new HashSet( + enhancedFeatureFlagLoadResult.EnhancedFeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name)); + + IEnumerable eligibleFeatureFlags = featureFlagLoadResult.FeatureFlags + .Where(setting => !ineligibleFfKeys.Contains(setting.Key)); + + Dictionary mappedFfData = await MapConfigurationSettings(eligibleFeatureFlags.ToDictionary(x => x.Key, x => x)).ConfigureAwait(false); foreach (KeyValuePair kvp in mappedFfData) { _mappedData[kvp.Key] = kvp.Value; } + + _enhancedFeatureFlags = enhancedFeatureFlagLoadResult.EnhancedFeatureFlags; } // @@ -434,7 +477,9 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { _watchedIndividualKvs = watchedIndividualKvs ?? _watchedIndividualKvs; - _watchedFfPages = ffEtags ?? _watchedFfPages; + _watchedFeatureFlagPages = featureFlagLoadResult?.Pages ?? _watchedFeatureFlagPages; + + _watchedEnhancedFeatureFlagPages = enhancedFeatureFlagLoadResult?.Pages ?? _watchedEnhancedFeatureFlagPages; _watchedKvPages = kvEtags ?? _watchedKvPages; @@ -453,7 +498,16 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => // PrepareData makes calls to KeyVault and may throw exceptions. But, we still update watchers before // SetData because repeating appconfig calls (by not updating watchers) won't help anything for keyvault calls. // As long as adapter.NeedsRefresh is true, we will attempt to update keyvault again the next time RefreshAsync is called. - SetData(await PrepareData(_mappedData, cancellationToken).ConfigureAwait(false)); + Dictionary preparedData = await PrepareData(_mappedData, cancellationToken).ConfigureAwait(false); + + IEnumerable> processedFeatureFlags = ProcessEnhancedFeatureFlags(_enhancedFeatureFlags); + + foreach (KeyValuePair kv in processedFeatureFlags) + { + preparedData[kv.Key] = kv.Value; + } + + SetData(preparedData); } } finally @@ -559,7 +613,7 @@ public void ProcessPushNotification(PushNotification pushNotification, TimeSpan? $"{nameof(pushNotification)}.{nameof(pushNotification.ResourceUri)}"); } - if (_configClientManager.UpdateSyncToken(pushNotification.ResourceUri, pushNotification.SyncToken)) + if (_clientManager.UpdateSyncToken(pushNotification.ResourceUri, pushNotification.SyncToken)) { if (_requestTracingEnabled && _requestTracingOptions != null) { @@ -614,29 +668,61 @@ private void SetDirty(TimeSpan? maxDelay) } } - private async Task> PrepareData(Dictionary data, CancellationToken cancellationToken = default) + private async Task> PrepareData( + Dictionary data, + CancellationToken cancellationToken = default) { var applicationData = new Dictionary(StringComparer.OrdinalIgnoreCase); // Reset old feature flag tracing in order to track the information present in the current response from server. _options.FeatureFlagTracing.ResetFeatureFlagTracing(); - // Reset old request tracing values for content type + // Reset old request tracing values for content type and enhanced feature flags if (_requestTracingEnabled && _requestTracingOptions != null) { _requestTracingOptions.ResetAiConfigurationTracing(); + + _requestTracingOptions.UsesEnhancedFeatureFlag = false; } + // The running index into the "feature_management:feature_flags" array. Feature flags emitted + // using the Microsoft schema advance this index; + int featureFlagIndex = 0; + foreach (KeyValuePair kvp in data) { - IEnumerable> keyValuePairs = null; + IEnumerable> keyValuePairs; if (_requestTracingEnabled && _requestTracingOptions != null) { _requestTracingOptions.UpdateAiConfigurationTracing(kvp.Value.ContentType); } - keyValuePairs = await ProcessAdapters(kvp.Value, cancellationToken).ConfigureAwait(false); + if (FeatureFlagConverter.IsFeatureFlag(kvp.Value)) + { + FeatureFlag featureFlag = FeatureFlagConverter.Parse(kvp.Value); + + _options.FeatureFlagTracing.Update(featureFlag); + + var metadata = new FeatureFlagMetadata(kvp.Value.Key, kvp.Value.Label, kvp.Value.ETag); + + keyValuePairs = FeatureFlagConverter.ToConfiguration( + featureFlag, + metadata, + AppConfigurationEndpoint, + _fmSchemaCompatibilityDisabled, + featureFlagIndex); + + // Only advance the index when the flag was emitted using the Microsoft schema + if (FeatureFlagConverter.UsesMicrosoftSchema(featureFlag, _fmSchemaCompatibilityDisabled)) + { + featureFlagIndex++; + } + } + else + { + keyValuePairs = await ProcessAdapters(kvp.Value, cancellationToken).ConfigureAwait(false); + } foreach (KeyValuePair kv in keyValuePairs) { @@ -658,6 +744,38 @@ private async Task> PrepareData(Dictionary> ProcessEnhancedFeatureFlags(IEnumerable featureFlags) + { + var processedFeatureFlags = new List>(); + + if (featureFlags == null || !featureFlags.Any()) + { + return processedFeatureFlags; + } + + if (_requestTracingEnabled && _requestTracingOptions != null) + { + _requestTracingOptions.UsesEnhancedFeatureFlag = true; + } + + int featureFlagIndex = _ffKeys.Count; + + foreach (EnhancedFeatureFlag featureFlag in featureFlags) + { + _options.FeatureFlagTracing.Update(featureFlag); + + foreach (KeyValuePair kv in EnhancedFeatureFlagConverter.ToConfiguration(featureFlag, AppConfigurationEndpoint, featureFlagIndex)) + { + processedFeatureFlags.Add(new KeyValuePair(kv.Key, kv.Value)); + } + + featureFlagIndex++; + } + + return processedFeatureFlags; + } + private async Task LoadAsync(bool ignoreFailures, CancellationToken cancellationToken) { var startupStopwatch = Stopwatch.StartNew(); @@ -670,7 +788,7 @@ private async Task LoadAsync(bool ignoreFailures, CancellationToken cancellation { while (true) { - IEnumerable clients = _configClientManager.GetClients(); + IEnumerable clients = _clientManager.GetClients(); if (_requestTracingEnabled && _requestTracingOptions != null) { @@ -723,7 +841,7 @@ e is RequestFailedException || { } } - private async Task TryInitializeAsync(IEnumerable clients, List startupExceptions, CancellationToken cancellationToken = default) + private async Task TryInitializeAsync(IEnumerable clients, List startupExceptions, CancellationToken cancellationToken = default) { try { @@ -769,32 +887,42 @@ private async Task TryInitializeAsync(IEnumerable cli return true; } - private async Task InitializeAsync(IEnumerable clients, CancellationToken cancellationToken = default) + private async Task InitializeAsync(IEnumerable clients, CancellationToken cancellationToken = default) { Dictionary data = null; Dictionary> kvEtags = new Dictionary>(); - Dictionary> ffEtags = new Dictionary>(); Dictionary watchedIndividualKvs = null; - HashSet ffKeys = new HashSet(); + FeatureFlagLoadResult featureFlagLoadResult = null; + EnhancedFeatureFlagLoadResult enhancedFeatureFlagLoadResult = null; await ExecuteWithFailOverPolicyAsync( clients, - async (client) => + async (appConfigClient) => { - data = await LoadSelected( - client, + data = await LoadKeyValues( + appConfigClient, kvEtags, - ffEtags, - _options.Selectors, - ffKeys, + _options.KeyValueSelectors, cancellationToken) .ConfigureAwait(false); watchedIndividualKvs = await LoadIndividualWatchedSettings( - client, + appConfigClient, data, cancellationToken) .ConfigureAwait(false); + + featureFlagLoadResult = await LoadFeatureFlags( + appConfigClient, + _options.FeatureFlagSelectors, + cancellationToken) + .ConfigureAwait(false); + + enhancedFeatureFlagLoadResult = await LoadEnhancedFeatureFlags( + appConfigClient, + _options.FeatureFlagSelectors, + cancellationToken) + .ConfigureAwait(false); }, cancellationToken) .ConfigureAwait(false); @@ -810,32 +938,51 @@ await ExecuteWithFailOverPolicyAsync( _nextCollectionRefreshTime = DateTimeOffset.UtcNow.Add(_options.KvCollectionRefreshInterval); } - if (data != null) + // Invalidate all the cached KeyVault secrets + foreach (IKeyValueAdapter adapter in _options.Adapters) { - // Invalidate all the cached KeyVault secrets - foreach (IKeyValueAdapter adapter in _options.Adapters) - { - adapter.OnChangeDetected(); - } + adapter.OnChangeDetected(); + } + + // Exclude any feature flags that are superseded by an enhanced feature flag with the same name. + var ineligibleFfKeys = new HashSet( + enhancedFeatureFlagLoadResult.EnhancedFeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name)); + + IEnumerable eligibleFeatureFlags = featureFlagLoadResult.FeatureFlags + .Where(setting => !ineligibleFfKeys.Contains(setting.Key)); + + _ffKeys = new HashSet( + eligibleFeatureFlags.Select(ff => ff.Key) + ); - Dictionary mappedData = await MapConfigurationSettings(data).ConfigureAwait(false); + foreach (ConfigurationSetting setting in eligibleFeatureFlags) + { + data[setting.Key] = setting; + } + + Dictionary mappedData = await MapConfigurationSettings(data).ConfigureAwait(false); - SetData(await PrepareData(mappedData, cancellationToken).ConfigureAwait(false)); + Dictionary preparedData = await PrepareData(mappedData, cancellationToken).ConfigureAwait(false); - _mappedData = mappedData; - _watchedKvPages = kvEtags; - _watchedFfPages = ffEtags; - _watchedIndividualKvs = watchedIndividualKvs; - _ffKeys = ffKeys; + foreach (KeyValuePair kv in ProcessEnhancedFeatureFlags(enhancedFeatureFlagLoadResult.EnhancedFeatureFlags)) + { + preparedData[kv.Key] = kv.Value; } + + SetData(preparedData); + + _mappedData = mappedData; + _watchedKvPages = kvEtags; + _watchedFeatureFlagPages = featureFlagLoadResult.Pages; + _watchedEnhancedFeatureFlagPages = enhancedFeatureFlagLoadResult.Pages; + _watchedIndividualKvs = watchedIndividualKvs; + _enhancedFeatureFlags = enhancedFeatureFlagLoadResult.EnhancedFeatureFlags; } - private async Task> LoadSelected( - ConfigurationClient client, + private async Task> LoadKeyValues( + IAppConfigurationClient client, Dictionary> kvPageWatchers, - Dictionary> ffPageWatchers, IEnumerable selectors, - HashSet ffKeys, CancellationToken cancellationToken) { Dictionary data = new Dictionary(); @@ -897,11 +1044,6 @@ await CallWithRequestTracing(async () => } data[setting.Key] = setting; - - if (loadOption.IsFeatureFlagSelector) - { - ffKeys.Add(setting.Key); - } } // The ETag will never be null here because it's not a conditional request @@ -914,14 +1056,7 @@ await CallWithRequestTracing(async () => } }).ConfigureAwait(false); - if (loadOption.IsFeatureFlagSelector) - { - ffPageWatchers[loadOption] = pageWatchers; - } - else - { - kvPageWatchers[loadOption] = pageWatchers; - } + kvPageWatchers[loadOption] = pageWatchers; } else { @@ -937,7 +1072,131 @@ await CallWithRequestTracing(async () => return data; } - private async Task> LoadSnapshotData(string snapshotName, ConfigurationClient client, CancellationToken cancellationToken) + // Loads feature flags (from the ".appconfig.featureflag/" key-value namespace) into `data` + // as configuration settings. Returns the watched pages per selector. + private async Task LoadFeatureFlags( + IAppConfigurationClient client, + IEnumerable featureFlagSelectors, + CancellationToken cancellationToken) + { + var featureFlags = new Dictionary(); + + var pages = new Dictionary>(); + + foreach (FeatureFlagSelector ffSelector in featureFlagSelectors) + { + var selector = new SettingSelector() + { + KeyFilter = FeatureManagementConstants.FeatureFlagMarker + ffSelector.NameFilter, + LabelFilter = ffSelector.LabelFilter + }; + + if (ffSelector.TagFilters != null) + { + foreach (string tagFilter in ffSelector.TagFilters) + { + selector.TagsFilter.Add(tagFilter); + } + } + + var pageWatchers = new List(); + + await CallWithRequestTracing(async () => + { + AsyncPageable pageableSettings = client.GetConfigurationSettingsAsync(selector, cancellationToken); + + await foreach (Page page in pageableSettings.AsPages(_options.ConfigurationSettingPageIterator).ConfigureAwait(false)) + { + using Response rawResponse = page.GetRawResponse(); + DateTimeOffset serverResponseTime = rawResponse.GetMsDate(); + + foreach (ConfigurationSetting setting in page.Values) + { + featureFlags[setting.Key] = setting; + } + + // The ETag will never be null here because it's not a conditional request + // Each successful response should have 200 status code and an ETag + pageWatchers.Add(new WatchedPage() + { + MatchConditions = new MatchConditions { IfNoneMatch = rawResponse.Headers.ETag }, + LastServerResponseTime = serverResponseTime + }); + } + }).ConfigureAwait(false); + + pages[ffSelector] = pageWatchers; + } + + return new FeatureFlagLoadResult + { + FeatureFlags = featureFlags.Values, + Pages = pages + }; + } + + // Loads standalone feature flags from the feature-flag endpoint into `featureFlags`. + // Returns the watched pages per selector. + private async Task LoadEnhancedFeatureFlags( + IAppConfigurationClient client, + IEnumerable featureFlagSelectors, + CancellationToken cancellationToken) + { + var featureFlags = new List(); + + var pages = new Dictionary>(); + + foreach (FeatureFlagSelector ffSelector in featureFlagSelectors) + { + var selector = new AppConfigFeatureFlagSelector + { + NameFilter = ffSelector.NameFilter, + LabelFilter = ffSelector.LabelFilter + }; + + if (ffSelector.TagFilters != null) + { + foreach (string tag in ffSelector.TagFilters) + { + selector.TagsFilter.Add(tag); + } + } + + var pageWatchers = new List(); + + await CallWithRequestTracing(async () => + { + AsyncPageable pageable = client.GetFeatureFlagsAsync(selector, cancellationToken); + + await foreach (Page page in pageable.AsPages(_options.FeatureFlagPageIterator).ConfigureAwait(false)) + { + using Response rawResponse = page.GetRawResponse(); + DateTimeOffset serverResponseTime = rawResponse.GetMsDate(); + + foreach (EnhancedFeatureFlag ff in page.Values) + { + featureFlags.Add(ff); + } + + pageWatchers.Add(new WatchedPage() + { + MatchConditions = new MatchConditions { IfNoneMatch = rawResponse.Headers.ETag }, + LastServerResponseTime = serverResponseTime + }); + } + }).ConfigureAwait(false); + + pages[ffSelector] = pageWatchers; + } + + return new EnhancedFeatureFlagLoadResult + { + EnhancedFeatureFlags = featureFlags, + Pages = pages + }; + } + + private async Task> LoadSnapshotData(string snapshotName, IAppConfigurationClient client, CancellationToken cancellationToken) { var resolvedSettings = new Dictionary(); @@ -976,7 +1235,7 @@ await CallWithRequestTracing(async () => } private async Task> LoadIndividualWatchedSettings( - ConfigurationClient client, + IAppConfigurationClient client, IDictionary existingSettings, CancellationToken cancellationToken) { @@ -1050,7 +1309,7 @@ private async Task> LoadInd } private async Task RefreshIndividualKvWatchers( - ConfigurationClient client, + IAppConfigurationClient client, List keyValueChanges, IEnumerable refreshableIndividualKvWatchers, Uri endpoint, @@ -1215,8 +1474,8 @@ private void UpdateNextRefreshTime(KeyValueWatcher changeWatcher) } private async Task ExecuteWithFailOverPolicyAsync( - IEnumerable clients, - Func> funcToExecute, + IEnumerable clients, + Func> funcToExecute, CancellationToken cancellationToken = default) { if (_requestTracingEnabled && _requestTracingOptions != null) @@ -1228,11 +1487,11 @@ private async Task ExecuteWithFailOverPolicyAsync( { int nextClientIndex = 0; - foreach (ConfigurationClient client in clients) + foreach (IAppConfigurationClient clientWrapper in clients) { nextClientIndex++; - if (_configClientManager.GetEndpointForClient(client) == _lastSuccessfulEndpoint) + if (clientWrapper.Endpoint == _lastSuccessfulEndpoint) { break; } @@ -1245,12 +1504,12 @@ private async Task ExecuteWithFailOverPolicyAsync( } } - using IEnumerator clientEnumerator = clients.GetEnumerator(); + using IEnumerator clientEnumerator = clients.GetEnumerator(); clientEnumerator.MoveNext(); - Uri previousEndpoint = _configClientManager.GetEndpointForClient(clientEnumerator.Current); - ConfigurationClient currentClient; + Uri previousEndpoint = clientEnumerator.Current?.Endpoint; + IAppConfigurationClient currentClient; while (true) { @@ -1265,7 +1524,7 @@ private async Task ExecuteWithFailOverPolicyAsync( T result = await funcToExecute(currentClient).ConfigureAwait(false); success = true; - _lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient); + _lastSuccessfulEndpoint = currentClient.Endpoint; _lastSuccessfulAttempt = DateTime.UtcNow; return result; @@ -1297,7 +1556,7 @@ private async Task ExecuteWithFailOverPolicyAsync( do { - UpdateClientBackoffStatus(_configClientManager.GetEndpointForClient(currentClient), success); + UpdateClientBackoffStatus(currentClient.Endpoint, success); clientEnumerator.MoveNext(); @@ -1311,7 +1570,7 @@ private async Task ExecuteWithFailOverPolicyAsync( } } - Uri currentEndpoint = _configClientManager.GetEndpointForClient(clientEnumerator.Current); + Uri currentEndpoint = clientEnumerator.Current?.Endpoint; if (previousEndpoint != currentEndpoint) { @@ -1328,8 +1587,8 @@ private async Task ExecuteWithFailOverPolicyAsync( } private async Task ExecuteWithFailOverPolicyAsync( - IEnumerable clients, - Func funcToExecute, + IEnumerable clients, + Func funcToExecute, CancellationToken cancellationToken = default) { await ExecuteWithFailOverPolicyAsync(clients, async (client) => @@ -1426,9 +1685,9 @@ private void EnsureAssemblyInspected() private void UpdateClientBackoffStatus(Uri endpoint, bool successful) { - if (!_configClientBackoffs.TryGetValue(endpoint, out ConfigurationClientBackoffStatus clientBackoffStatus)) + if (!_clientBackoffs.TryGetValue(endpoint, out ClientBackoffStatus clientBackoffStatus)) { - clientBackoffStatus = new ConfigurationClientBackoffStatus(); + clientBackoffStatus = new ClientBackoffStatus(); } if (successful) @@ -1446,13 +1705,13 @@ private void UpdateClientBackoffStatus(Uri endpoint, bool successful) clientBackoffStatus.BackoffEndTime = DateTimeOffset.UtcNow.Add(backoffDuration); } - _configClientBackoffs[endpoint] = clientBackoffStatus; + _clientBackoffs[endpoint] = clientBackoffStatus; } private async Task HaveCollectionsChanged( IEnumerable selectors, Dictionary> pageWatchers, - ConfigurationClient client, + IAppConfigurationClient client, CancellationToken cancellationToken) { bool haveCollectionsChanged = false; @@ -1479,6 +1738,74 @@ await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Wa return haveCollectionsChanged; } + private async Task HaveFeatureFlagsChanged( + IEnumerable selectors, + Dictionary> featureFlagPageWatchers, + IAppConfigurationClient client, + CancellationToken cancellationToken) + { + bool haveFeatureFlagsChanged = false; + + foreach (FeatureFlagSelector selector in selectors) + { + if (featureFlagPageWatchers.TryGetValue(selector, out IEnumerable pages) && + pages != null) + { + var ffSelector = new KeyValueSelector + { + KeyFilter = FeatureManagementConstants.FeatureFlagMarker + selector.NameFilter, + LabelFilter = selector.LabelFilter, + TagFilters = selector.TagFilters, + }; + + await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, + async () => haveFeatureFlagsChanged = await client.HaveCollectionsChanged( + ffSelector, + pages, + _options.ConfigurationSettingPageIterator, + makeConditionalRequest: !_options.IsAfdUsed, + cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + + if (haveFeatureFlagsChanged) + { + return true; + } + } + } + + return haveFeatureFlagsChanged; + } + + private async Task HaveEnhancedFeatureFlagsChanged( + IEnumerable selectors, + Dictionary> featureFlagPageWatchers, + IAppConfigurationClient client, + CancellationToken cancellationToken) + { + bool haveEnhancedFeatureFlagsChanged = false; + + foreach (FeatureFlagSelector selector in selectors) + { + if (featureFlagPageWatchers.TryGetValue(selector, out IEnumerable featureFlagPages) && + featureFlagPages != null) + { + await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, + async () => haveEnhancedFeatureFlagsChanged = await client.HaveFeatureFlagsChanged( + selector, + featureFlagPages, + _options.FeatureFlagPageIterator, + cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + + if (haveEnhancedFeatureFlagsChanged) + { + return true; + } + } + } + + return haveEnhancedFeatureFlagsChanged; + } + private async Task ProcessKeyValueChangesAsync( IEnumerable keyValueChanges, Dictionary mappedData, @@ -1535,7 +1862,7 @@ private async Task ProcessKeyValueChangesAsync( public void Dispose() { - (_configClientManager as ConfigurationClientManager)?.Dispose(); + (_clientManager as AppConfigurationClientManager)?.Dispose(); _activitySource?.Dispose(); } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs index 230b99cba..22d06fc79 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -67,17 +67,30 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) IEnumerable endpoints; + FeatureFlagClientOptions featureFlagClientOptions = options.FeatureFlagClientOptions; + + if (options.IsAfdUsed) + { + featureFlagClientOptions.AddPolicy(new AfdPolicy(), HttpPipelinePosition.PerRetry); + } + + IAzureClientFactory featureFlagClientFactory; + if (options.ConnectionStrings != null) { endpoints = options.ConnectionStrings.Select(cs => new Uri(ConnectionStringUtils.Parse(cs, ConnectionStringUtils.EndpointSection))); clientFactory ??= new AzureAppConfigurationClientFactory(options.ConnectionStrings, options.ClientOptions); + + featureFlagClientFactory = new AzureAppConfigurationFeatureFlagClientFactory(options.ConnectionStrings, featureFlagClientOptions); } else if (options.Endpoints != null && options.Credential != null) { endpoints = options.Endpoints; clientFactory ??= new AzureAppConfigurationClientFactory(options.Credential, options.ClientOptions); + + featureFlagClientFactory = new AzureAppConfigurationFeatureFlagClientFactory(options.Credential, featureFlagClientOptions); } else { @@ -86,11 +99,11 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) if (options.IsAfdUsed) { - provider = new AzureAppConfigurationProvider(new AfdConfigurationClientManager(clientFactory, endpoints.First()), options, _optional); + provider = new AzureAppConfigurationProvider(new AfdClientManager(clientFactory, featureFlagClientFactory, endpoints.First()), options, _optional); } else { - provider = new AzureAppConfigurationProvider(new ConfigurationClientManager(clientFactory, endpoints, options.ReplicaDiscoveryEnabled, options.LoadBalancingEnabled), options, _optional); + provider = new AzureAppConfigurationProvider(new AppConfigurationClientManager(clientFactory, featureFlagClientFactory, endpoints, options.ReplicaDiscoveryEnabled, options.LoadBalancingEnabled), options, _optional); } } catch (InvalidOperationException ex) // InvalidOperationException is thrown when any problems are found while configuring AzureAppConfigurationOptions or when SDK fails to create a configurationClient. diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientWrapper.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientWrapper.cs deleted file mode 100644 index f12cc43af..000000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientWrapper.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Azure.Data.AppConfiguration; -using System; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration -{ - internal class ConfigurationClientWrapper - { - public ConfigurationClientWrapper(Uri endpoint, ConfigurationClient configurationClient) - { - Endpoint = endpoint; - Client = configurationClient; - } - - public ConfigurationClient Client { get; private set; } - public Uri Endpoint { get; private set; } - } -} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs index e3e7f6160..95cb72200 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs @@ -38,6 +38,7 @@ internal class RequestTracingConstants public const string FailoverRequestTag = "Failover"; public const string PushRefreshTag = "PushRefresh"; public const string AfdTag = "AFD"; + public const string EnhancedFeatureFlagTag = "EnhFF"; public const string FeatureFlagFilterTypeKey = "Filter"; public const string CustomFilter = "CSTM"; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index 572a8b4e5..574ae4ed5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -15,7 +15,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class ConfigurationClientExtensions { - public static async Task GetKeyValueChange(this ConfigurationClient client, ConfigurationSetting setting, CancellationToken cancellationToken) + public static async Task GetKeyValueChange(this IAppConfigurationClient client, ConfigurationSetting setting, CancellationToken cancellationToken) { if (setting == null) { @@ -67,7 +67,7 @@ public static async Task GetKeyValueChange(this ConfigurationCli } public static async Task HaveCollectionsChanged( - this ConfigurationClient client, + this IAppConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable pageWatchers, IConfigurationSettingPageIterator pageIterator, diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/FeatureFlagClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/FeatureFlagClientExtensions.cs new file mode 100644 index 000000000..55c6db05f --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/FeatureFlagClientExtensions.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; +using AppConfigFeatureFlag = Azure.Data.AppConfiguration.FeatureFlag; +using AppConfigFeatureFlagSelector = Azure.Data.AppConfiguration.FeatureFlagSelector; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions +{ + internal static class FeatureFlagClientExtensions + { + public static async Task HaveFeatureFlagsChanged( + this IAppConfigurationClient client, + Models.FeatureFlagSelector featureFlagSelector, + IEnumerable pageWatchers, + IFeatureFlagPageIterator pageIterator, + CancellationToken cancellationToken) + { + if (pageWatchers == null) + { + throw new ArgumentNullException(nameof(pageWatchers)); + } + + if (featureFlagSelector == null) + { + throw new ArgumentNullException(nameof(featureFlagSelector)); + } + + var selector = new AppConfigFeatureFlagSelector + { + NameFilter = featureFlagSelector.NameFilter, + LabelFilter = featureFlagSelector.LabelFilter + }; + + if (featureFlagSelector.TagFilters != null) + { + foreach (string tag in featureFlagSelector.TagFilters) + { + selector.TagsFilter.Add(tag); + } + } + + AsyncPageable pageable = client.GetFeatureFlagsAsync(selector, cancellationToken); + + using IEnumerator existingPageWatcherEnumerator = pageWatchers.GetEnumerator(); + + await foreach (Page page in pageable.AsPages(pageIterator, pageWatchers.Select(p => p.MatchConditions)).ConfigureAwait(false)) + { + using Response rawResponse = page.GetRawResponse(); + DateTimeOffset serverResponseTime = rawResponse.GetMsDate(); + + // Return true if the lists of etags are different + if (!existingPageWatcherEnumerator.MoveNext() || + (rawResponse.Status == (int)HttpStatusCode.OK && + // if the server response time is later than last server response time, the change is considered detected + serverResponseTime >= existingPageWatcherEnumerator.Current.LastServerResponseTime && + !existingPageWatcherEnumerator.Current.MatchConditions.IfNoneMatch.Equals(rawResponse.Headers.ETag))) + { + return true; + } + } + + // Need to check if pages were deleted and no change was found within the new shorter list of pages + return existingPageWatcherEnumerator.MoveNext(); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureFlagPageExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureFlagPageExtensions.cs new file mode 100644 index 000000000..9af10e0d5 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureFlagPageExtensions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Data.AppConfiguration; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal static class FeatureFlagPageExtensions + { + public static IAsyncEnumerable> AsPages(this AsyncPageable pageable, IFeatureFlagPageIterator pageIterator) + { + // + // Allow custom iteration + if (pageIterator != null) + { + return pageIterator.IteratePages(pageable); + } + + return pageable.AsPages(); + } + + public static IAsyncEnumerable> AsPages(this AsyncPageable pageable, IFeatureFlagPageIterator pageIterator, IEnumerable matchConditions) + { + // + // Allow custom iteration + if (pageIterator != null) + { + return pageIterator.IteratePages(pageable, matchConditions); + } + + return pageable.AsPages(matchConditions); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/EnhancedFeatureFlagConverter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/EnhancedFeatureFlagConverter.cs new file mode 100644 index 000000000..711a9131b --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/EnhancedFeatureFlagConverter.cs @@ -0,0 +1,420 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Data.AppConfiguration; +using EnhancedFeatureFlag = Azure.Data.AppConfiguration.FeatureFlag; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement +{ + /// + /// Converts a enhanced feature flag (returned by the feature-flag endpoint as an Azure SDK + /// ) directly into the flattened feature-management configuration key-values + /// consumed by Microsoft.FeatureManagement. + /// + internal static class EnhancedFeatureFlagConverter + { + /// + /// Produces the feature-management configuration key-values for a single enhanced feature flag. + /// Enhanced feature flags are always emitted using the Microsoft schema. + /// + /// The feature flag to convert. + /// The endpoint used to build the feature flag reference for telemetry. + /// The index of the feature flag in the feature flags array. + public static IEnumerable> ToConfiguration( + EnhancedFeatureFlag flag, + Uri endpoint, + int featureFlagIndex) + { + string key = FeatureManagementConstants.FeatureFlagMarker + (flag.Name ?? string.Empty); + + var metadata = new FeatureFlagMetadata(key, flag.Label, flag.Etag ?? default); + + return ProcessMicrosoftSchemaFeatureFlag(flag, metadata, endpoint, featureFlagIndex); + } + + private static List> ProcessMicrosoftSchemaFeatureFlag( + EnhancedFeatureFlag featureFlag, + FeatureFlagMetadata metadata, + Uri endpoint, + int featureFlagIndex) + { + var keyValues = new List>(); + + if (string.IsNullOrEmpty(featureFlag.Name)) + { + return keyValues; + } + + string featureFlagPath = $"{FeatureManagementConstants.FeatureManagementSectionName}:{FeatureManagementConstants.FeatureFlagsSectionName}:{featureFlagIndex}"; + + bool enabled = featureFlag.Enabled; + + keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.Id}", featureFlag.Name)); + + keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.Enabled}", enabled.ToString())); + + if (enabled) + { + if (featureFlag.Conditions?.Filters != null && featureFlag.Conditions.Filters.Any()) + { + // + // Conditionally based on feature filters + for (int i = 0; i < featureFlag.Conditions.Filters.Count; i++) + { + FeatureFilter clientFilter = featureFlag.Conditions.Filters[i]; + + string clientFiltersPath = $"{featureFlagPath}:{FeatureManagementConstants.Conditions}:{FeatureManagementConstants.ClientFilters}:{i}"; + + keyValues.Add(new KeyValuePair($"{clientFiltersPath}:{FeatureManagementConstants.Name}", clientFilter.Name)); + + foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(BuildParametersElement(clientFilter.Parameters))) + { + keyValues.Add(new KeyValuePair($"{clientFiltersPath}:{FeatureManagementConstants.Parameters}:{kvp.Key}", kvp.Value)); + } + } + + // + // process RequirementType only when filters are not empty + if (featureFlag.Conditions.RequirementType != null) + { + keyValues.Add(new KeyValuePair( + $"{featureFlagPath}:{FeatureManagementConstants.Conditions}:{FeatureManagementConstants.RequirementType}", + featureFlag.Conditions.RequirementType.Value.ToString())); + } + } + } + + if (featureFlag.Variants != null) + { + int i = 0; + + foreach (FeatureFlagVariantDefinition featureVariant in featureFlag.Variants) + { + string variantsPath = $"{featureFlagPath}:{FeatureManagementConstants.Variants}:{i}"; + + keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.Name}", featureVariant.Name)); + + foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(BuildVariantValueElement(featureVariant.Value, featureVariant.ContentType))) + { + keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.ConfigurationValue}" + + (string.IsNullOrEmpty(kvp.Key) ? "" : $":{kvp.Key}"), kvp.Value)); + } + + if (featureVariant.StatusOverride != null) + { + keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.StatusOverride}", featureVariant.StatusOverride.Value.ToString())); + } + + i++; + } + } + + if (featureFlag.Allocation != null) + { + FeatureFlagAllocation allocation = featureFlag.Allocation; + + string allocationPath = $"{featureFlagPath}:{FeatureManagementConstants.Allocation}"; + + if (allocation.DefaultWhenDisabled != null) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.DefaultWhenDisabled}", allocation.DefaultWhenDisabled)); + } + + if (allocation.DefaultWhenEnabled != null) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.DefaultWhenEnabled}", allocation.DefaultWhenEnabled)); + } + + if (allocation.User != null) + { + int j = 0; + + foreach (UserAllocation userAllocation in allocation.User) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.UserAllocation}:{j}:{FeatureManagementConstants.Variant}", userAllocation.Variant)); + + int k = 0; + + foreach (string user in userAllocation.Users) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.UserAllocation}:{j}:{FeatureManagementConstants.Users}:{k}", user)); + + k++; + } + + j++; + } + } + + if (allocation.Group != null) + { + int j = 0; + + foreach (GroupAllocation groupAllocation in allocation.Group) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.GroupAllocation}:{j}:{FeatureManagementConstants.Variant}", groupAllocation.Variant)); + + int k = 0; + + foreach (string group in groupAllocation.Groups) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.GroupAllocation}:{j}:{FeatureManagementConstants.Groups}:{k}", group)); + + k++; + } + + j++; + } + } + + if (allocation.Percentile != null) + { + int j = 0; + + foreach (PercentileAllocation percentileAllocation in allocation.Percentile) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{j}:{FeatureManagementConstants.Variant}", percentileAllocation.Variant)); + + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{j}:{FeatureManagementConstants.From}", percentileAllocation.From.ToString())); + + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{j}:{FeatureManagementConstants.To}", percentileAllocation.To.ToString())); + + j++; + } + } + + if (allocation.Seed != null) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.Seed}", allocation.Seed)); + } + } + + if (featureFlag.Telemetry != null) + { + FeatureFlagTelemetryConfiguration telemetry = featureFlag.Telemetry; + + string telemetryPath = $"{featureFlagPath}:{FeatureManagementConstants.Telemetry}"; + + if (telemetry.Enabled) + { + if (telemetry.Metadata != null) + { + foreach (KeyValuePair kvp in telemetry.Metadata) + { + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{kvp.Key}", kvp.Value)); + } + } + + if (endpoint != null) + { + string featureFlagReference = $"{endpoint.AbsoluteUri}ff/{metadata.Key}{(!string.IsNullOrWhiteSpace(metadata.Label) ? $"?label={metadata.Label}" : "")}"; + + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.FeatureFlagReference}", featureFlagReference)); + } + + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.ETag}", metadata.ETag.ToString())); + + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Enabled}", telemetry.Enabled.ToString())); + + if (featureFlag.Allocation != null) + { + string allocationId = CalculateAllocationId(featureFlag); + + if (allocationId != null) + { + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.AllocationId}", allocationId)); + } + } + } + } + + return keyValues; + } + + private static string CalculateAllocationId(EnhancedFeatureFlag flag) + { + Debug.Assert(flag.Allocation != null); + + StringBuilder inputBuilder = new StringBuilder(); + + // Seed + inputBuilder.Append($"seed={flag.Allocation.Seed ?? string.Empty}"); + + var allocatedVariants = new HashSet(); + + // DefaultWhenEnabled + if (flag.Allocation.DefaultWhenEnabled != null) + { + allocatedVariants.Add(flag.Allocation.DefaultWhenEnabled); + } + + inputBuilder.Append($"\ndefault_when_enabled={flag.Allocation.DefaultWhenEnabled ?? string.Empty}"); + + // Percentiles + inputBuilder.Append("\npercentiles="); + + if (flag.Allocation.Percentile != null && flag.Allocation.Percentile.Any()) + { + IEnumerable sortedPercentiles = flag.Allocation.Percentile + .Where(p => p.From != p.To) + .OrderBy(p => p.From) + .ToList(); + + allocatedVariants.UnionWith(sortedPercentiles.Select(p => p.Variant)); + + inputBuilder.Append(string.Join(";", sortedPercentiles.Select(p => $"{p.From},{p.Variant.ToBase64String()},{p.To}"))); + } + + // If there's no custom seed and no variants allocated, stop now and return null + if (flag.Allocation.Seed == null && + !allocatedVariants.Any()) + { + return null; + } + + // Variants + inputBuilder.Append("\nvariants="); + + if (allocatedVariants.Any() && flag.Variants != null && flag.Variants.Any()) + { + IEnumerable sortedVariants = flag.Variants + .Where(variant => allocatedVariants.Contains(variant.Name)) + .OrderBy(variant => variant.Name) + .ToList(); + + inputBuilder.Append(string.Join(";", sortedVariants.Select(v => + { + var variantValue = string.Empty; + + JsonElement configurationValue = BuildVariantValueElement(v.Value, v.ContentType); + + if (configurationValue.ValueKind != JsonValueKind.Null && configurationValue.ValueKind != JsonValueKind.Undefined) + { + variantValue = configurationValue.SerializeWithSortedKeys(); + } + + return $"{v.Name.ToBase64String()},{(variantValue)}"; + }))); + } + + // Example input string + // input == "seed=123abc\ndefault_when_enabled=Control\npercentiles=0,Blshdk,20;20,Test,100\nvariants=TdLa,standard;Qfcd,special" + string input = inputBuilder.ToString(); + + using (SHA256 sha256 = SHA256.Create()) + { + byte[] truncatedHash = new byte[15]; + Array.Copy(sha256.ComputeHash(Encoding.UTF8.GetBytes(input)), truncatedHash, 15); + return truncatedHash.ToBase64Url(); + } + } + + // The SDK exposes filter parameters as IDictionary. The feature-management + // flattening produces per-leaf keys (e.g. Audience:Users:0), so build a JsonElement here. Parameter + // values that are JSON-encoded strings are embedded as parsed JSON so the flattening produces the + // nested keys that feature-management filters bind against. + private static JsonElement BuildParametersElement(IDictionary parameters) + { + if (parameters == null || parameters.Count == 0) + { + return default; + } + + using var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + + foreach (KeyValuePair kvp in parameters) + { + writer.WritePropertyName(kvp.Key); + WriteParameterValue(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + + using JsonDocument doc = JsonDocument.Parse(stream.ToArray()); + + return doc.RootElement.Clone(); + } + + private static void WriteParameterValue(Utf8JsonWriter writer, string value) + { + if (value == null) + { + writer.WriteNullValue(); + + return; + } + + string trimmed = value.TrimStart(); + + if (trimmed.Length > 0 && (trimmed[0] == '{' || trimmed[0] == '[')) + { + try + { + using JsonDocument doc = JsonDocument.Parse(value); + doc.RootElement.WriteTo(writer); + + return; + } + catch (JsonException) + { + // Fall through and write the original literal string. + } + } + + writer.WriteStringValue(value); + } + + // Variant values are exposed by the SDK as a string plus a content type. When the content type + // is JSON-shaped, embed the parsed JSON so consumers see a real object/array/number rather than + // a string literal; otherwise produce a JSON string element. + private static JsonElement BuildVariantValueElement(string value, string contentType) + { + if (value == null) + { + return default; + } + + bool looksLikeJson = !string.IsNullOrEmpty(contentType) && + contentType.IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0; + + if (looksLikeJson) + { + try + { + using JsonDocument doc = JsonDocument.Parse(value); + + return doc.RootElement.Clone(); + } + catch (JsonException) + { + // Fall through to writing as a raw string when the body is not valid JSON. + } + } + + using var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStringValue(value); + } + + using JsonDocument stringDoc = JsonDocument.Parse(stream.ToArray()); + + return stringDoc.RootElement.Clone(); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagConverter.cs similarity index 93% rename from src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs rename to src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagConverter.cs index fdd7f2fdf..5b4a5b224 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagConverter.cs @@ -17,42 +17,14 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { - internal class FeatureManagementKeyValueAdapter : IKeyValueAdapter + /// + /// Converts a feature flag (stored as a in the + /// ".appconfig.featureflag/" key-value namespace) into the flattened feature-management configuration + /// key-values consumed by Microsoft.FeatureManagement. + /// + internal static class FeatureFlagConverter { - private FeatureFlagTracing _featureFlagTracing; - private int _featureFlagIndex = 0; - private bool _fmSchemaCompatibilityDisabled = false; - - public FeatureManagementKeyValueAdapter(FeatureFlagTracing featureFlagTracing) - { - _featureFlagTracing = featureFlagTracing ?? throw new ArgumentNullException(nameof(featureFlagTracing)); - - _fmSchemaCompatibilityDisabled = EnvironmentVariableHelper.GetBoolOrDefault(EnvironmentVariableNames.FmSchemacompatibilityDisabled); - } - - public Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken) - { - FeatureFlag featureFlag = ParseFeatureFlag(setting.Key, setting.Value); - - var keyValues = new List>(); - - // Check if we need to process the feature flag using the microsoft schema - if (_fmSchemaCompatibilityDisabled || - (featureFlag.Variants != null && featureFlag.Variants.Any()) || - featureFlag.Allocation != null || - featureFlag.Telemetry != null) - { - keyValues = ProcessMicrosoftSchemaFeatureFlag(featureFlag, setting, endpoint); - } - else - { - keyValues = ProcessDotnetSchemaFeatureFlag(featureFlag, setting, endpoint); - } - - return Task.FromResult>>(keyValues); - } - - public bool CanProcess(ConfigurationSetting setting) + public static bool IsFeatureFlag(ConfigurationSetting setting) { if (setting == null || string.IsNullOrWhiteSpace(setting.Value) || @@ -61,33 +33,57 @@ public bool CanProcess(ConfigurationSetting setting) return false; } - if (setting.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker)) - { - return true; - } - return setting.ContentType.TryParseContentType(out ContentType contentType) && contentType.IsFeatureFlag(); } - public bool NeedsRefresh() + public static FeatureFlag Parse(ConfigurationSetting setting) { - return false; + return ParseFeatureFlag(setting.Key, setting.Value); } - public void OnChangeDetected(ConfigurationSetting setting = null) + /// + /// Produces the feature-management configuration key-values for a single feature flag. + /// + /// The parsed feature flag. + /// Metadata used to build the feature flag reference for telemetry. + /// The endpoint used to build the feature flag reference for telemetry. + /// + /// When true, all feature flags are emitted using the Microsoft schema. + /// + /// + /// The current index in the "feature_management:feature_flags" array to use if the flag is emitted + /// using the Microsoft schema. + /// + public static IEnumerable> ToConfiguration( + FeatureFlag featureFlag, + FeatureFlagMetadata metadata, + Uri endpoint, + bool fmSchemaCompatibilityDisabled, + int featureFlagIndex) { - return; + // Check if we need to process the feature flag using the microsoft schema + if (UsesMicrosoftSchema(featureFlag, fmSchemaCompatibilityDisabled)) + { + return ProcessMicrosoftSchemaFeatureFlag(featureFlag, metadata, endpoint, featureFlagIndex); + } + + return ProcessDotnetSchemaFeatureFlag(featureFlag); } - public void OnConfigUpdated() + /// + /// Determines whether the feature flag is emitted using the Microsoft schema (and therefore + /// occupies a slot in the "feature_management:feature_flags" array) rather than the .NET schema. + /// + public static bool UsesMicrosoftSchema(FeatureFlag featureFlag, bool fmSchemaCompatibilityDisabled) { - _featureFlagIndex = 0; - - return; + return fmSchemaCompatibilityDisabled || + (featureFlag.Variants != null && featureFlag.Variants.Any()) || + featureFlag.Allocation != null || + featureFlag.Telemetry != null; } - private List> ProcessDotnetSchemaFeatureFlag(FeatureFlag featureFlag, ConfigurationSetting setting, Uri endpoint) + private static List> ProcessDotnetSchemaFeatureFlag(FeatureFlag featureFlag) { var keyValues = new List>(); @@ -110,8 +106,6 @@ private List> ProcessDotnetSchemaFeatureFlag(Featur { ClientFilter clientFilter = featureFlag.Conditions.ClientFilters[i]; - _featureFlagTracing.UpdateFeatureFilterTracing(clientFilter.Name); - string clientFiltersPath = $"{featureFlagPath}:{FeatureManagementConstants.DotnetSchemaEnabledFor}:{i}"; keyValues.Add(new KeyValuePair($"{clientFiltersPath}:Name", clientFilter.Name)); @@ -140,7 +134,7 @@ private List> ProcessDotnetSchemaFeatureFlag(Featur return keyValues; } - private List> ProcessMicrosoftSchemaFeatureFlag(FeatureFlag featureFlag, ConfigurationSetting setting, Uri endpoint) + private static List> ProcessMicrosoftSchemaFeatureFlag(FeatureFlag featureFlag, FeatureFlagMetadata metadata, Uri endpoint, int featureFlagIndex) { var keyValues = new List>(); @@ -149,9 +143,7 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea return keyValues; } - string featureFlagPath = $"{FeatureManagementConstants.FeatureManagementSectionName}:{FeatureManagementConstants.FeatureFlagsSectionName}:{_featureFlagIndex}"; - - _featureFlagIndex++; + string featureFlagPath = $"{FeatureManagementConstants.FeatureManagementSectionName}:{FeatureManagementConstants.FeatureFlagsSectionName}:{featureFlagIndex}"; keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.Id}", featureFlag.Id)); @@ -167,8 +159,6 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea { ClientFilter clientFilter = featureFlag.Conditions.ClientFilters[i]; - _featureFlagTracing.UpdateFeatureFilterTracing(clientFilter.Name); - string clientFiltersPath = $"{featureFlagPath}:{FeatureManagementConstants.Conditions}:{FeatureManagementConstants.ClientFilters}:{i}"; keyValues.Add(new KeyValuePair($"{clientFiltersPath}:{FeatureManagementConstants.Name}", clientFilter.Name)); @@ -213,8 +203,6 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea i++; } - - _featureFlagTracing.NotifyMaxVariants(i); } if (featureFlag.Allocation != null) @@ -293,8 +281,6 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea if (allocation.Seed != null) { - _featureFlagTracing.UsesSeed = true; - keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.Seed}", allocation.Seed)); } } @@ -307,8 +293,6 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea if (telemetry.Enabled) { - _featureFlagTracing.UsesTelemetry = true; - if (telemetry.Metadata != null) { foreach (KeyValuePair kvp in telemetry.Metadata) @@ -319,12 +303,12 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea if (endpoint != null) { - string featureFlagReference = $"{endpoint.AbsoluteUri}kv/{setting.Key}{(!string.IsNullOrWhiteSpace(setting.Label) ? $"?label={setting.Label}" : "")}"; + string featureFlagReference = $"{endpoint.AbsoluteUri}kv/{metadata.Key}{(!string.IsNullOrWhiteSpace(metadata.Label) ? $"?label={metadata.Label}" : "")}"; keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.FeatureFlagReference}", featureFlagReference)); } - keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.ETag}", setting.ETag.ToString())); + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.ETag}", metadata.ETag.ToString())); keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Enabled}", telemetry.Enabled.ToString())); @@ -343,7 +327,7 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea return keyValues; } - private string CalculateAllocationId(FeatureFlag flag) + private static string CalculateAllocationId(FeatureFlag flag) { Debug.Assert(flag.Allocation != null); @@ -419,7 +403,7 @@ private string CalculateAllocationId(FeatureFlag flag) } } - private FormatException CreateFeatureFlagFormatException(string jsonPropertyName, string settingKey, string foundJsonValueKind, string expectedJsonValueKind) + private static FormatException CreateFeatureFlagFormatException(string jsonPropertyName, string settingKey, string foundJsonValueKind, string expectedJsonValueKind) { return new FormatException(string.Format( ErrorMessages.FeatureFlagInvalidJsonProperty, @@ -429,7 +413,7 @@ private FormatException CreateFeatureFlagFormatException(string jsonPropertyName expectedJsonValueKind)); } - private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) + private static FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) { var featureFlag = new FeatureFlag(); @@ -612,7 +596,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) return featureFlag; } - private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, string settingKey) + private static FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, string settingKey) { var featureConditions = new FeatureConditions(); @@ -698,7 +682,7 @@ private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, stri return featureConditions; } - private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string settingKey) + private static ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string settingKey) { var clientFilter = new ClientFilter(); @@ -759,7 +743,7 @@ private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string setting return clientFilter; } - private FeatureAllocation ParseFeatureAllocation(ref Utf8JsonReader reader, string settingKey) + private static FeatureAllocation ParseFeatureAllocation(ref Utf8JsonReader reader, string settingKey) { var featureAllocation = new FeatureAllocation(); @@ -974,7 +958,7 @@ private FeatureAllocation ParseFeatureAllocation(ref Utf8JsonReader reader, stri return featureAllocation; } - private FeatureUserAllocation ParseFeatureUserAllocation(ref Utf8JsonReader reader, string settingKey) + private static FeatureUserAllocation ParseFeatureUserAllocation(ref Utf8JsonReader reader, string settingKey) { var featureUserAllocation = new FeatureUserAllocation(); @@ -1057,7 +1041,7 @@ private FeatureUserAllocation ParseFeatureUserAllocation(ref Utf8JsonReader read return featureUserAllocation; } - private FeatureGroupAllocation ParseFeatureGroupAllocation(ref Utf8JsonReader reader, string settingKey) + private static FeatureGroupAllocation ParseFeatureGroupAllocation(ref Utf8JsonReader reader, string settingKey) { var featureGroupAllocation = new FeatureGroupAllocation(); @@ -1140,7 +1124,7 @@ private FeatureGroupAllocation ParseFeatureGroupAllocation(ref Utf8JsonReader re return featureGroupAllocation; } - private FeaturePercentileAllocation ParseFeaturePercentileAllocation(ref Utf8JsonReader reader, string settingKey) + private static FeaturePercentileAllocation ParseFeaturePercentileAllocation(ref Utf8JsonReader reader, string settingKey) { var featurePercentileAllocation = new FeaturePercentileAllocation(); @@ -1223,7 +1207,7 @@ private FeaturePercentileAllocation ParseFeaturePercentileAllocation(ref Utf8Jso return featurePercentileAllocation; } - private FeatureVariant ParseFeatureVariant(ref Utf8JsonReader reader, string settingKey) + private static FeatureVariant ParseFeatureVariant(ref Utf8JsonReader reader, string settingKey) { var featureVariant = new FeatureVariant(); @@ -1294,7 +1278,7 @@ private FeatureVariant ParseFeatureVariant(ref Utf8JsonReader reader, string set return featureVariant; } - private FeatureTelemetry ParseFeatureTelemetry(ref Utf8JsonReader reader, string settingKey) + private static FeatureTelemetry ParseFeatureTelemetry(ref Utf8JsonReader reader, string settingKey) { var featureTelemetry = new FeatureTelemetry(); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagMetadata.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagMetadata.cs new file mode 100644 index 000000000..13759350f --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagMetadata.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement +{ + /// + /// Identity information about a feature flag that is needed when emitting feature-management + /// configuration key-values (in particular for telemetry metadata such as the feature flag + /// reference and ETag). This decouples the emit logic from the source of the feature flag, + /// allowing both feature flags (loaded as ) + /// and enhanced feature flags (loaded from the dedicated feature-flag endpoint) to share it. + /// + internal readonly struct FeatureFlagMetadata + { + public FeatureFlagMetadata(string key, string label, ETag etag) + { + Key = key; + Label = label; + ETag = etag; + } + + /// + /// The full key of the feature flag, including the ".appconfig.featureflag/" prefix. + /// + public string Key { get; } + + /// + /// The label of the feature flag. + /// + public string Label { get; } + + /// + /// The ETag of the feature flag. + /// + public ETag ETag { get; } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 4b6d56d6d..d66ab45db 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -17,9 +17,9 @@ public class FeatureFlagOptions private TimeSpan _refreshInterval = RefreshConstants.DefaultFeatureFlagRefreshInterval; /// - /// A collection of . + /// A collection of . /// - internal List FeatureFlagSelectors = new List(); + internal List FeatureFlagSelectors = new List(); /// /// The time after which feature flags can be refreshed. Must be greater than or equal to 1 second. @@ -115,14 +115,11 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = } } - string featureFlagPrefix = FeatureManagementConstants.FeatureFlagMarker + featureFlagFilter; - - FeatureFlagSelectors.AppendUnique(new KeyValueSelector + FeatureFlagSelectors.AppendUnique(new FeatureFlagSelector { - KeyFilter = featureFlagPrefix, + NameFilter = featureFlagFilter, LabelFilter = labelFilter, - TagFilters = tagFilters, - IsFeatureFlagSelector = true + TagFilters = tagFilters }); return this; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs index 8c696e498..319c4a429 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs @@ -6,6 +6,8 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using Azure.Data.AppConfiguration; +using EnhancedFeatureFlag = Azure.Data.AppConfiguration.FeatureFlag; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { @@ -76,6 +78,64 @@ public void NotifyMaxVariants(int currentFlagTotalVariants) } } + /// + /// Records feature filter, variant, seed and telemetry usage for an enhanced feature flag. + /// + public void Update(EnhancedFeatureFlag flag) + { + if (flag.Enabled && flag.Conditions?.Filters != null) + { + foreach (FeatureFilter filter in flag.Conditions.Filters) + { + UpdateFeatureFilterTracing(filter.Name); + } + } + + if (flag.Variants != null) + { + NotifyMaxVariants(flag.Variants.Count()); + } + + if (flag.Allocation?.Seed != null) + { + UsesSeed = true; + } + + if (flag.Telemetry != null && flag.Telemetry.Enabled) + { + UsesTelemetry = true; + } + } + + /// + /// Records feature filter, variant, seed and telemetry usage for a feature flag. + /// + public void Update(FeatureFlag flag) + { + if (flag.Enabled && flag.Conditions?.ClientFilters != null) + { + foreach (ClientFilter filter in flag.Conditions.ClientFilters) + { + UpdateFeatureFilterTracing(filter.Name); + } + } + + if (flag.Variants != null) + { + NotifyMaxVariants(flag.Variants.Count()); + } + + if (flag.Allocation?.Seed != null) + { + UsesSeed = true; + } + + if (flag.Telemetry != null && flag.Telemetry.Enabled) + { + UsesTelemetry = true; + } + } + /// /// Returns a formatted string containing code names, indicating which feature filters are used by the application. /// diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IAppConfigurationClient.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IAppConfigurationClient.cs new file mode 100644 index 000000000..508753f45 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IAppConfigurationClient.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Data.AppConfiguration; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + /// + /// A single client abstraction for an Azure App Configuration endpoint that exposes the subset of + /// operations the provider needs from both and + /// . The implementation holds both underlying SDK clients internally so + /// that the provider can work with a single client per endpoint. + /// + internal interface IAppConfigurationClient + { + /// + /// The endpoint of the Azure App Configuration store this client communicates with. + /// + Uri Endpoint { get; } + + AsyncPageable GetConfigurationSettingsAsync(SettingSelector selector, CancellationToken cancellationToken); + + AsyncPageable CheckConfigurationSettingsAsync(SettingSelector selector, CancellationToken cancellationToken); + + Task> GetConfigurationSettingAsync(string key, string label, CancellationToken cancellationToken); + + Task> GetConfigurationSettingAsync(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken); + + Task> GetSnapshotAsync(string snapshotName, CancellationToken cancellationToken); + + AsyncPageable GetConfigurationSettingsForSnapshotAsync(string snapshotName, CancellationToken cancellationToken); + + AsyncPageable GetFeatureFlagsAsync(FeatureFlagSelector selector, CancellationToken cancellationToken); + + void UpdateSyncToken(string syncToken); + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IAppConfigurationClientManager.cs similarity index 67% rename from src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationClientManager.cs rename to src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IAppConfigurationClientManager.cs index 798e35e65..5407943d6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationClientManager.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IAppConfigurationClientManager.cs @@ -8,14 +8,12 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { - internal interface IConfigurationClientManager + internal interface IAppConfigurationClientManager { - IEnumerable GetClients(); + IEnumerable GetClients(); void RefreshClients(); bool UpdateSyncToken(Uri endpoint, string syncToken); - - Uri GetEndpointForClient(ConfigurationClient client); } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IFeatureFlagPageIterator.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IFeatureFlagPageIterator.cs new file mode 100644 index 000000000..87467cb14 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IFeatureFlagPageIterator.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Data.AppConfiguration; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal interface IFeatureFlagPageIterator + { + IAsyncEnumerable> IteratePages(AsyncPageable pageable); + + IAsyncEnumerable> IteratePages(AsyncPageable pageable, IEnumerable matchConditions); + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/FeatureFlagSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/FeatureFlagSelector.cs new file mode 100644 index 000000000..000954019 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/FeatureFlagSelector.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Models +{ + /// + /// A selector used to control what feature flags are retrieved from Azure App Configuration. + /// A single feature flag selector is used to query both feature flags (key-values prefixed + /// with ".appconfig.featureflag/") and enhanced feature flags returned by the dedicated feature-flag endpoint. + /// + internal class FeatureFlagSelector + { + /// + /// A filter that determines the set of feature flag names that are included in the configuration provider. + /// The name filter does not include the ".appconfig.featureflag/" prefix. + /// + public string NameFilter { get; set; } + + /// + /// A filter that determines what label to use when selecting feature flags for the configuration provider. + /// + public string LabelFilter { get; set; } + + /// + /// A filter that determines what tags to require when selecting feature flags for the configuration provider. + /// + public IEnumerable TagFilters { get; set; } + + /// + /// Determines whether the specified object is equal to the current object. + /// + /// The object to compare with the current object. + /// true if the specified object is equal to the current object; otherwise, false. + public override bool Equals(object obj) + { + if (obj is FeatureFlagSelector selector) + { + return NameFilter == selector.NameFilter + && LabelFilter == selector.LabelFilter + && (TagFilters == null + ? selector.TagFilters == null + : selector.TagFilters != null && new HashSet(TagFilters).SetEquals(selector.TagFilters)); + } + + return false; + } + + /// + /// Serves as the hash function. + /// + /// A hash code for the current object. + public override int GetHashCode() + { + 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( + NameFilter, + LabelFilter, + tagFiltersString); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs index d6e32ddc0..41023cc07 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -97,6 +97,11 @@ internal class RequestTracingOptions /// public bool UsesSnapshotReference { get; set; } = false; + /// + /// Flag to indicate whether enhanced feature flags are used. + /// + public bool UsesEnhancedFeatureFlag { get; set; } = false; + /// /// Resets the AI configuration tracing flags. /// @@ -137,7 +142,8 @@ public bool UsesAnyTracingFeature() UsesAIConfiguration || UsesAIChatCompletionConfiguration || UsesSnapshotReference || - IsAfdUsed; + IsAfdUsed || + UsesEnhancedFeatureFlag; } /// @@ -208,6 +214,16 @@ public string CreateFeaturesString() sb.Append(RequestTracingConstants.AfdTag); } + if (UsesEnhancedFeatureFlag) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.EnhancedFeatureFlagTag); + } + return sb.ToString(); } } diff --git a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj index 1e5aaae18..3d43e5f38 100644 --- a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj +++ b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj @@ -11,7 +11,7 @@ - + diff --git a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs index 1d2250291..8b2154a5c 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs @@ -390,6 +390,8 @@ public async Task AfdTests_FeatureFlagsRefresh() .Returns(mockAsyncPageable2) // watch request, should not trigger refresh .Returns(mockAsyncPageable3); // watch request, should trigger refresh + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); IConfigurationRefresher refresher = null; var config = new ConfigurationBuilder() @@ -398,6 +400,7 @@ public async Task AfdTests_FeatureFlagsRefresh() options.ConnectAzureFrontDoor(afdEndpoint); options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.FeatureFlagPageIterator = new MockFeatureFlagPageIterator(); options.UseFeatureFlags(o => o.SetRefreshInterval(TimeSpan.FromSeconds(1))); refresher = options.GetRefresher(); }) diff --git a/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs index 72b3a8260..30ca2797c 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // using Azure; @@ -15,6 +15,7 @@ using System.Threading; using System.Threading.Tasks; using Xunit; +using ClientWrapper = Microsoft.Extensions.Configuration.AzureAppConfiguration.AppConfigurationClient; namespace Tests.AzureAppConfiguration { @@ -48,11 +49,11 @@ public async Task FailOverTests_ReturnsAllClientsIfAllBackedOff() .Throws(new RequestFailedException(503, "Request failed.")); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); - ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); - ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + ClientWrapper cw1 = new ClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.PrimaryConfigStoreEndpoint)); + ClientWrapper cw2 = new ClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.SecondaryConfigStoreEndpoint)); - var clientList = new List() { cw1, cw2 }; - var configClientManager = new ConfigurationClientManager(clientList); + var clientList = new List() { cw1, cw2 }; + var configClientManager = new AppConfigurationClientManager(clientList); // The client enumerator should return 2 clients Assert.Equal(2, configClientManager.GetClients().Count()); @@ -116,11 +117,11 @@ public void FailOverTests_PropagatesNonFailOverableExceptions() .Throws(new RequestFailedException(503, "Request failed.")); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); - ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); - ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + ClientWrapper cw1 = new ClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.PrimaryConfigStoreEndpoint)); + ClientWrapper cw2 = new ClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.SecondaryConfigStoreEndpoint)); - var clientList = new List() { cw1, cw2 }; - var configClientManager = new ConfigurationClientManager(clientList); + var clientList = new List() { cw1, cw2 }; + var configClientManager = new AppConfigurationClientManager(clientList); // The client enumerator should return 2 clients Assert.Equal(2, configClientManager.GetClients().Count()); @@ -175,11 +176,11 @@ public async Task FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); - ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); - ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + ClientWrapper cw1 = new ClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.PrimaryConfigStoreEndpoint)); + ClientWrapper cw2 = new ClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.SecondaryConfigStoreEndpoint)); - var clientList = new List() { cw1, cw2 }; - var configClientManager = new ConfigurationClientManager(clientList); + var clientList = new List() { cw1, cw2 }; + var configClientManager = new AppConfigurationClientManager(clientList); // The client enumerator should return 2 clients Assert.Equal(2, configClientManager.GetClients().Count()); @@ -242,11 +243,11 @@ public void FailOverTests_AutoFailover() .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); - ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); - ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + ClientWrapper cw1 = new ClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.PrimaryConfigStoreEndpoint)); + ClientWrapper cw2 = new ClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.SecondaryConfigStoreEndpoint)); - var clientList = new List() { cw1 }; - var autoFailoverList = new List() { cw2 }; + var clientList = new List() { cw1 }; + var autoFailoverList = new List() { cw2 }; var mockedConfigClientManager = new MockedConfigurationClientManager(clientList, autoFailoverList); // Should not throw exception. @@ -269,9 +270,11 @@ public void FailOverTests_AutoFailover() public void FailOverTests_ValidateEndpoints() { var clientFactory = new AzureAppConfigurationClientFactory(new DefaultAzureCredential(), new ConfigurationClientOptions()); + var featureFlagClientFactory = new AzureAppConfigurationFeatureFlagClientFactory(new DefaultAzureCredential(), new FeatureFlagClientOptions()); - var configClientManager = new ConfigurationClientManager( + var configClientManager = new AppConfigurationClientManager( clientFactory, + featureFlagClientFactory, new[] { new Uri("https://foobar.azconfig.io") }, true, false); @@ -285,8 +288,9 @@ public void FailOverTests_ValidateEndpoints() Assert.False(configClientManager.IsValidEndpoint("azure.appconfig.azure.com")); Assert.False(configClientManager.IsValidEndpoint("azure.azconfig.bad.io")); - var configClientManager2 = new ConfigurationClientManager( + var configClientManager2 = new AppConfigurationClientManager( clientFactory, + featureFlagClientFactory, new[] { new Uri("https://foobar.appconfig.azure.com") }, true, false); @@ -300,8 +304,9 @@ public void FailOverTests_ValidateEndpoints() Assert.False(configClientManager2.IsValidEndpoint("azure.badappconfig.azure.com")); Assert.False(configClientManager2.IsValidEndpoint("azure.appconfigbad.azure.com")); - var configClientManager3 = new ConfigurationClientManager( + var configClientManager3 = new AppConfigurationClientManager( clientFactory, + featureFlagClientFactory, new[] { new Uri("https://foobar.azconfig-test.io") }, true, false); @@ -309,8 +314,9 @@ public void FailOverTests_ValidateEndpoints() Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig-test.io")); Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig.io")); - var configClientManager4 = new ConfigurationClientManager( + var configClientManager4 = new AppConfigurationClientManager( clientFactory, + featureFlagClientFactory, new[] { new Uri("https://foobar.z1.appconfig-test.azure.com") }, true, false); @@ -324,9 +330,11 @@ public void FailOverTests_ValidateEndpoints() public void FailOverTests_GetNoDynamicClient() { var clientFactory = new AzureAppConfigurationClientFactory(new DefaultAzureCredential(), new ConfigurationClientOptions()); + var featureFlagClientFactory = new AzureAppConfigurationFeatureFlagClientFactory(new DefaultAzureCredential(), new FeatureFlagClientOptions()); - var configClientManager = new ConfigurationClientManager( + var configClientManager = new AppConfigurationClientManager( clientFactory, + featureFlagClientFactory, new[] { new Uri("https://azure.azconfig.io") }, true, false); @@ -360,11 +368,11 @@ public void FailOverTests_NetworkTimeout() .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); - ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, client1); - ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + ClientWrapper cw1 = new ClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, client1, TestHelpers.CreateFeatureFlagClient(TestHelpers.PrimaryConfigStoreEndpoint)); + ClientWrapper cw2 = new ClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.SecondaryConfigStoreEndpoint)); - var clientList = new List() { cw1 }; - var autoFailoverList = new List() { cw2 }; + 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 @@ -445,11 +453,11 @@ public async Task FailOverTests_AllClientsBackedOffAfterNonFailoverableException .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); - ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); - ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + ClientWrapper cw1 = new ClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.PrimaryConfigStoreEndpoint)); + ClientWrapper cw2 = new ClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.SecondaryConfigStoreEndpoint)); - var clientList = new List() { cw1, cw2 }; - var configClientManager = new ConfigurationClientManager(clientList); + var clientList = new List() { cw1, cw2 }; + var configClientManager = new AppConfigurationClientManager(clientList); // Verify 2 clients are available Assert.Equal(2, configClientManager.GetClients().Count()); diff --git a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs index 05642fe8a..83362d9ed 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs @@ -6,6 +6,7 @@ using Azure.Core.Testing; using Azure.Data.AppConfiguration; using Azure.Data.AppConfiguration.Tests; +using EnhancedFeatureFlag = Azure.Data.AppConfiguration.FeatureFlag; using Azure.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; @@ -735,6 +736,8 @@ public void UsesFeatureFlags() var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var featureFlags = new List { _kv }; mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -762,17 +765,264 @@ public void UsesFeatureFlags() Assert.Equal("/Date(1578686400000)/", config["FeatureManagement:Beta:EnabledFor:3:Parameters:End"]); } + [Fact] + public void StandaloneFeatureFlagsAreIndexedAfterMicrosoftSchemaClassicFlags() + { + var mockClient = new Mock(MockBehavior.Strict); + + var standaloneFlags = new List + { + CreateFeatureFlag("StandaloneA", enabled: true, etag: "sa-1"), + CreateFeatureFlag("StandaloneB", enabled: false, etag: "sb-1") + }; + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient, standaloneFlags); + + // A single Microsoft-schema classic flag (has variants) occupies a slot in the feature_flags array. + var settingCollection = new List + { + CreateClassicFeatureFlag("ClassicMs", enabled: true, etag: "ms-1") + }; + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(settingCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.FeatureFlagPageIterator = new MockFeatureFlagPageIterator(); + options.UseFeatureFlags(); + }) + .Build(); + + // The Microsoft-schema classic flag occupies index 0. + Assert.Equal("ClassicMs", config["feature_management:feature_flags:0:id"]); + + // Standalone flags are appended after the classic flag, in order. + Assert.Equal("StandaloneA", config["feature_management:feature_flags:1:id"]); + Assert.Equal("StandaloneB", config["feature_management:feature_flags:2:id"]); + Assert.Null(config["feature_management:feature_flags:3:id"]); + + // All flags bind via GetChildren(). + Assert.Equal(3, config.GetSection("feature_management:feature_flags").GetChildren().Count()); + } + + [Fact] + public void StandaloneFeatureFlagsAreIndexedAfterMicrosoftSchemaClassicFlagsOnly() + { + var mockClient = new Mock(MockBehavior.Strict); + + var standaloneFlags = new List + { + CreateFeatureFlag("StandaloneA", enabled: true, etag: "sa-1") + }; + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient, standaloneFlags); + + // _kv (Beta) is a .NET-schema classic flag (no variants/allocation/telemetry). It is emitted under the + // "FeatureManagement" section and does not occupy a slot in the "feature_management:feature_flags" array. + // ClassicMs is a Microsoft-schema classic flag that does occupy a slot. + var settingCollection = new List + { + _kv, + CreateClassicFeatureFlag("ClassicMs", enabled: true, etag: "ms-1") + }; + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(settingCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.FeatureFlagPageIterator = new MockFeatureFlagPageIterator(); + options.UseFeatureFlags(); + }) + .Build(); + + // The .NET-schema classic flag is emitted under the "FeatureManagement" section. + Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]); + + // The Microsoft-schema classic flag occupies index 0. Standalone flags are appended after the classic + // feature flag count (2), which leaves a harmless gap at index 1 for the .NET-schema classic flag that + // does not occupy an array slot. + Assert.Equal("ClassicMs", config["feature_management:feature_flags:0:id"]); + Assert.Null(config["feature_management:feature_flags:1:id"]); + Assert.Equal("StandaloneA", config["feature_management:feature_flags:2:id"]); + Assert.Equal(2, config.GetSection("feature_management:feature_flags").GetChildren().Count()); + } + + [Fact] + public void StandaloneFeatureFlagSupersedesClassicFeatureFlagWithSameName() + { + var mockClient = new Mock(MockBehavior.Strict); + + // A standalone flag named "Shared" (disabled) should supersede the classic flag with the same name. + var standaloneFlags = new List + { + CreateFeatureFlag("Shared", enabled: false, etag: "sa-1") + }; + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient, standaloneFlags); + + // Distinguish the classic feature-flag query (key filter prefixed with the feature-flag marker) from the + // regular key-value query so that the classic "Shared" flag is only returned by the feature-flag query. + mockClient.Setup(c => c.GetConfigurationSettingsAsync( + It.Is(s => s.KeyFilter != null && s.KeyFilter.StartsWith(FeatureManagementConstants.FeatureFlagMarker)), + It.IsAny())) + .Returns(() => new MockAsyncPageable(new List + { + CreateClassicFeatureFlag("Shared", enabled: true, etag: "ms-1") + })); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync( + It.Is(s => s.KeyFilter == null || !s.KeyFilter.StartsWith(FeatureManagementConstants.FeatureFlagMarker)), + It.IsAny())) + .Returns(() => new MockAsyncPageable(new List())); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.FeatureFlagPageIterator = new MockFeatureFlagPageIterator(); + options.UseFeatureFlags(); + }) + .Build(); + + // Only one flag named "Shared" is present, and it is the standalone (disabled) version. + Assert.Equal("Shared", config["feature_management:feature_flags:0:id"]); + Assert.Equal("False", config["feature_management:feature_flags:0:enabled"]); + Assert.Null(config["feature_management:feature_flags:1:id"]); + Assert.Single(config.GetSection("feature_management:feature_flags").GetChildren()); + } + + [Fact] + public async Task IndividualKvRefreshDoesNotCorruptFeatureFlagIndices() + { + var testKey1 = ConfigurationModelFactory.ConfigurationSetting("TestKey1", "v1", label: null, contentType: "text", eTag: new ETag("kv-1")); + var classicMs = CreateClassicFeatureFlag("ClassicMs", enabled: true, etag: "ms-1"); + var loadCollection = new List { testKey1, classicMs }; + + var changedTestKey1 = ConfigurationModelFactory.ConfigurationSetting("TestKey1", "v2", label: null, contentType: "text", eTag: new ETag("kv-2")); + + var standaloneFlags = new List + { + CreateFeatureFlag("StandaloneA", enabled: true, etag: "sa-1") + }; + + var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient, standaloneFlags); + + // The classic feature-flag query (key filter prefixed with the feature-flag marker) returns only the + // classic feature flag, matching the server-side key filter. + mockClient.Setup(c => c.GetConfigurationSettingsAsync( + It.Is(s => s.KeyFilter != null && s.KeyFilter.StartsWith(FeatureManagementConstants.FeatureFlagMarker)), + It.IsAny())) + .Returns(() => new MockAsyncPageable(new List { classicMs })); + + // The regular key-value query returns the watched key-value along with the classic flag, which + // LoadKeyValues strips out of the key-value data. + mockClient.Setup(c => c.GetConfigurationSettingsAsync( + It.Is(s => s.KeyFilter == null || !s.KeyFilter.StartsWith(FeatureManagementConstants.FeatureFlagMarker)), + It.IsAny())) + .Returns(() => new MockAsyncPageable(loadCollection)); + + // The classic feature-flag collection reports unchanged (304) on refresh. + var classicCheckPageable = new MockAsyncPageable(loadCollection); + classicCheckPageable.UpdateCollection(loadCollection); + + mockClient.Setup(c => c.CheckConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(classicCheckPageable); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string k, string l, CancellationToken ct) => Response.FromValue(testKey1, new MockResponse(200))); + + // The individually-watched key-value reports a change on refresh. + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ConfigurationSetting s, bool onlyIfChanged, CancellationToken ct) => Response.FromValue(changedTestKey1, new MockResponse(200))); + + IConfigurationRefresher refresher = null; + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.FeatureFlagPageIterator = new MockFeatureFlagPageIterator(); + options.ConfigureRefresh(refresh => refresh.Register("TestKey1").SetRefreshInterval(RefreshInterval)); + options.UseFeatureFlags(ff => ff.SetRefreshInterval(TimeSpan.FromSeconds(10))); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Initial state: ClassicMs @ index 0, StandaloneA @ index 1. + Assert.Equal("v1", config["TestKey1"]); + Assert.Equal("ClassicMs", config["feature_management:feature_flags:0:id"]); + Assert.Equal("StandaloneA", config["feature_management:feature_flags:1:id"]); + + // Sleep to let the refresh interval elapse, then refresh (only the watched key-value changed). + Thread.Sleep(RefreshInterval); + await refresher.RefreshAsync(); + + // The watched key-value was updated. + Assert.Equal("v2", config["TestKey1"]); + + // The feature-flag indices are preserved: the standalone flag must not be re-emitted starting at index 0 + // and overwrite the classic flag's slot. + Assert.Equal("ClassicMs", config["feature_management:feature_flags:0:id"]); + Assert.Equal("StandaloneA", config["feature_management:feature_flags:1:id"]); + Assert.Null(config["feature_management:feature_flags:2:id"]); + Assert.Equal(2, config.GetSection("feature_management:feature_flags").GetChildren().Count()); + } + + private EnhancedFeatureFlag CreateFeatureFlag(string name, bool enabled, string etag) + { + return ConfigurationModelFactory.FeatureFlag( + name: name, + enabled: enabled, + label: null, + description: null, + conditions: null, + variants: null, + allocation: null, + telemetry: null, + tags: null, + lastModified: null, + etag: new ETag(etag)); + } + + private ConfigurationSetting CreateClassicFeatureFlag(string id, bool enabled, string etag) + { + return ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + id, + value: $@" + {{ + ""id"": ""{id}"", + ""enabled"": {enabled.ToString().ToLowerInvariant()}, + ""variants"": [ {{ ""name"": ""On"", ""configuration_value"": true }} ] + }}", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag(etag)); + } + [Fact] public async Task WatchesFeatureFlags() { var mockResponse = new MockResponse(200); - var featureFlags = new List { _kv }; var mockAsyncPageable = new MockAsyncPageable(featureFlags); var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Callback(() => mockAsyncPageable.UpdateCollection(featureFlags)) .Returns(mockAsyncPageable); @@ -849,6 +1099,8 @@ public async Task WatchesFeatureFlagsUsingCacheExpirationInterval() var mockClient = new Mock(MockBehavior.Strict); var mockAsyncPageable = new MockAsyncPageable(featureFlags); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Callback(() => mockAsyncPageable.UpdateCollection(featureFlags)) .Returns(mockAsyncPageable); @@ -925,6 +1177,8 @@ public async Task SkipRefreshIfRefreshIntervalHasNotElapsed() var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var mockAsyncPageable = new MockAsyncPageable(featureFlags); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -1000,6 +1254,8 @@ public async Task SkipRefreshIfCacheNotExpired() var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var mockAsyncPageable = new MockAsyncPageable(featureFlags); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -1078,7 +1334,7 @@ public void PreservesDefaultQuery() }); var options = new AzureAppConfigurationOptions(); - options.ClientOptions.Transport = mockTransport; + options.ConfigureClientOptions(o => o.Transport = mockTransport); var clientManager = TestHelpers.CreateMockedConfigurationClientManager(options); var builder = new ConfigurationBuilder(); @@ -1088,7 +1344,7 @@ public void PreservesDefaultQuery() options.UseFeatureFlags(); }).Build(); - bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("/kv?api-version=2023-11-01&key=%2A&label=%00")); + bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("/kv?api-version=2026-05-01-preview&key=%2A&label=%00")); bool queriedFeatureFlags = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains(Uri.EscapeDataString(FeatureManagementConstants.FeatureFlagMarker))); Assert.True(performedDefaultQuery); @@ -1106,7 +1362,7 @@ public void QueriesFeatureFlags() }); var options = new AzureAppConfigurationOptions(); - options.ClientOptions.Transport = mockTransport; + options.ConfigureClientOptions(o => o.Transport = mockTransport); var clientManager = TestHelpers.CreateMockedConfigurationClientManager(options); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => @@ -1116,7 +1372,7 @@ public void QueriesFeatureFlags() }) .Build(); - bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("/kv?api-version=2023-11-01&key=%2A&label=%00")); + bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("/kv?api-version=2026-05-01-preview&key=%2A&label=%00")); bool queriedFeatureFlags = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains(Uri.EscapeDataString(FeatureManagementConstants.FeatureFlagMarker))); Assert.True(performedDefaultQuery); @@ -1130,6 +1386,8 @@ public async Task DoesNotUseEtagForFeatureFlagRefresh() var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Callback(() => mockAsyncPageable.UpdateCollection(new List { _kv })) .Returns(mockAsyncPageable); @@ -1163,6 +1421,8 @@ public void SelectFeatureFlags() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); var featureFlagPrefix = "App1"; var labelFilter = "App1_Label"; @@ -1201,6 +1461,8 @@ public void SelectOrderDoesNotAffectLoad() var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + List kvCollection = new List { ConfigurationModelFactory.ConfigurationSetting("TestKey1", "TestValue1", "label", @@ -1257,6 +1519,8 @@ public void TestNullAndMissingValuesForConditions() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); var refreshInterval = TimeSpan.FromSeconds(1); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -1291,6 +1555,8 @@ public void InvalidFeatureFlagFormatsThrowFormatException() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); var refreshInterval = TimeSpan.FromSeconds(1); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -1332,6 +1598,8 @@ public void AlternateValidFeatureFlagFormats() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); var refreshInterval = TimeSpan.FromSeconds(1); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -1375,6 +1643,8 @@ public void MultipleSelectsInSameUseFeatureFlags() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); var prefix1 = "App1"; var prefix2 = "App2"; var label1 = "App1_Label"; @@ -1416,6 +1686,8 @@ public void KeepSelectorPrecedenceAfterDedup() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); var prefix = "Feature1"; var label1 = "App1_Label"; var label2 = "App2_Label"; @@ -1485,6 +1757,8 @@ public void MultipleCallsToUseFeatureFlags() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); var prefix1 = "App1"; var prefix2 = "App2"; var label1 = "App1_Label"; @@ -1529,6 +1803,8 @@ public void MultipleCallsToUseFeatureFlagsWithSelectAndLabel() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); var prefix1 = "App1"; var label1 = "App1_Label"; var label2 = "App2_Label"; @@ -1573,6 +1849,8 @@ public async Task DifferentCacheExpirationsForMultipleFeatureFlagRegistrations() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); var prefix1 = "App1"; var prefix2 = "App2"; var label1 = "App1_Label"; @@ -1680,6 +1958,8 @@ public async Task OverwrittenRefreshIntervalForSameFeatureFlagRegistrations() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); var refreshInterval1 = TimeSpan.FromSeconds(1); var refreshInterval2 = TimeSpan.FromSeconds(60); IConfigurationRefresher refresher = null; @@ -1755,6 +2035,8 @@ public async Task SelectAndRefreshSingleFeatureFlag() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); var prefix1 = "Feature1"; var label1 = "App1_Label"; IConfigurationRefresher refresher = null; @@ -1828,6 +2110,8 @@ public async Task ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefre var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var mockAsyncPageable = new MockAsyncPageable(featureFlags); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -1916,6 +2200,8 @@ public async Task ValidateFeatureFlagsUnchangedLogged() var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var mockAsyncPageable = new MockAsyncPageable(featureFlags); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -1949,6 +2235,7 @@ public async Task ValidateFeatureFlagsUnchangedLogged() { options.ClientManager = mockClientManager; options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.FeatureFlagPageIterator = new MockFeatureFlagPageIterator(); options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval)); options.ConfigureRefresh(refreshOptions => { @@ -2000,6 +2287,8 @@ public async Task MapTransformFeatureFlagWithRefresh() var mockClient = new Mock(MockBehavior.Strict); var mockAsyncPageable = new MockAsyncPageable(featureFlags); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Callback(() => mockAsyncPageable.UpdateCollection(featureFlags)) .Returns(mockAsyncPageable); @@ -2094,6 +2383,8 @@ public void WithVariants() var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_variantFeatureFlagCollection)); @@ -2175,6 +2466,8 @@ public void WithTelemetry() var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_telemetryFeatureFlagCollection)); @@ -2206,6 +2499,8 @@ public void WithAllocationId() var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_allocationIdFeatureFlagCollection)); @@ -2272,20 +2567,36 @@ public void WithRequirementType() mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(featureFlags)); - var config = new ConfigurationBuilder() - .AddAzureAppConfiguration(options => - { - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.UseFeatureFlags(); - }) - .Build(); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + + try + { + // Force Microsoft schema for all flags so requirement_type is emitted under the Microsoft schema paths. + Environment.SetEnvironmentVariable(EnvironmentVariableNames.FmSchemacompatibilityDisabled, "true"); - Assert.Null(config["feature_management:feature_flags:0:requirement_type"]); - Assert.Equal("Feature_NoFilters", config["feature_management:feature_flags:0:id"]); - Assert.Equal("All", config["feature_management:feature_flags:1:conditions:requirement_type"]); - Assert.Equal("Feature_RequireAll", config["feature_management:feature_flags:1:id"]); - Assert.Equal("Any", config["feature_management:feature_flags:2:conditions:requirement_type"]); - Assert.Equal("Feature_RequireAny", config["feature_management:feature_flags:2:id"]); + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.UseFeatureFlags(); + }) + .Build(); + + // Index 0 is _kv2 (MyFeature2), which has a client filter but no requirement_type. + Assert.Equal("MyFeature2", config["feature_management:feature_flags:0:id"]); + Assert.Null(config["feature_management:feature_flags:0:conditions:requirement_type"]); + + Assert.Null(config["feature_management:feature_flags:1:conditions:requirement_type"]); + Assert.Equal("Feature_NoFilters", config["feature_management:feature_flags:1:id"]); + Assert.Equal("All", config["feature_management:feature_flags:2:conditions:requirement_type"]); + Assert.Equal("Feature_RequireAll", config["feature_management:feature_flags:2:id"]); + Assert.Equal("Any", config["feature_management:feature_flags:3:conditions:requirement_type"]); + Assert.Equal("Feature_RequireAny", config["feature_management:feature_flags:3:id"]); + } + finally + { + Environment.SetEnvironmentVariable(EnvironmentVariableNames.FmSchemacompatibilityDisabled, null); + } } [Fact] @@ -2315,6 +2626,8 @@ public void ThrowsOnIncorrectJsonTypes() var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + foreach (ConfigurationSetting setting in settings) { var featureFlags = new List { setting }; @@ -2350,6 +2663,8 @@ public void EnvironmentVariableForcesMicrosoftSchemaForAllFlags() mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(mixedSchemaFlags)); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + try { // Act - Set environment variable to force Microsoft schema diff --git a/tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs b/tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs index eaca360ff..7d7b85c65 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs @@ -360,7 +360,7 @@ spanning multiple lines */ ", config["OnlyComments"]); } - private IConfigurationClientManager GetMockConfigurationClientManager(List _kvCollection) + private IAppConfigurationClientManager GetMockConfigurationClientManager(List _kvCollection) { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); diff --git a/tests/Tests.AzureAppConfiguration/Unit/LoadBalancingTests.cs b/tests/Tests.AzureAppConfiguration/Unit/LoadBalancingTests.cs index 4429c7be7..f825b0c7b 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/LoadBalancingTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/LoadBalancingTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // using Azure; @@ -13,6 +13,7 @@ using System.Threading; using System.Threading.Tasks; using Xunit; +using ClientWrapper = Microsoft.Extensions.Configuration.AzureAppConfiguration.AppConfigurationClient; namespace Tests.AzureAppConfiguration { @@ -48,11 +49,11 @@ public async Task LoadBalancingTests_UsesAllEndpoints() .ReturnsAsync(Response.FromValue(kv, mockResponse)); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); - ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); - ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + ClientWrapper cw1 = new ClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.PrimaryConfigStoreEndpoint)); + ClientWrapper cw2 = new ClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.SecondaryConfigStoreEndpoint)); - var clientList = new List() { cw1, cw2 }; - var configClientManager = new ConfigurationClientManager(clientList); + var clientList = new List() { cw1, cw2 }; + var configClientManager = new AppConfigurationClientManager(clientList); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => @@ -111,11 +112,11 @@ public async Task LoadBalancingTests_UsesClientAfterBackoffEnds() .ReturnsAsync(Response.FromValue(kv, mockResponse)); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); - ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); - ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + ClientWrapper cw1 = new ClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.PrimaryConfigStoreEndpoint)); + ClientWrapper cw2 = new ClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.SecondaryConfigStoreEndpoint)); - var clientList = new List() { cw1, cw2 }; - var configClientManager = new ConfigurationClientManager(clientList); + var clientList = new List() { cw1, cw2 }; + var configClientManager = new AppConfigurationClientManager(clientList); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => diff --git a/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs b/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs index 9fd87b887..157a322a0 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // using Azure; @@ -17,6 +17,7 @@ using System.Threading; using System.Threading.Tasks; using Xunit; +using ClientWrapper = Microsoft.Extensions.Configuration.AzureAppConfiguration.AppConfigurationClient; namespace Tests.AzureAppConfiguration { @@ -344,11 +345,11 @@ public async Task ValidateFailoverToDifferentEndpointMessageLoggedAfterFailover( mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true); mockClient2.Setup(c => c.Equals(mockClient1)).Returns(true); - ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); - ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + ClientWrapper cw1 = new ClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.PrimaryConfigStoreEndpoint)); + ClientWrapper cw2 = new ClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object, TestHelpers.CreateFeatureFlagClient(TestHelpers.SecondaryConfigStoreEndpoint)); - var clientList = new List() { cw1, cw2 }; - var configClientManager = new ConfigurationClientManager(clientList); + var clientList = new List() { cw1, cw2 }; + var configClientManager = new AppConfigurationClientManager(clientList); string warningInvocation = ""; using var _ = new AzureEventSourceListener( diff --git a/tests/Tests.AzureAppConfiguration/Unit/MockedConfigurationClientManager.cs b/tests/Tests.AzureAppConfiguration/Unit/MockedConfigurationClientManager.cs index 0216d1bef..6f17686c5 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/MockedConfigurationClientManager.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/MockedConfigurationClientManager.cs @@ -9,20 +9,20 @@ namespace Tests.AzureAppConfiguration { - internal class MockedConfigurationClientManager : IConfigurationClientManager + internal class MockedConfigurationClientManager : IAppConfigurationClientManager { - IList _clients; - IList _autoFailoverClients; + IList _clients; + IList _autoFailoverClients; internal int UpdateSyncTokenCalled { get; set; } = 0; - public MockedConfigurationClientManager(IEnumerable clients) + public MockedConfigurationClientManager(IEnumerable clients) { _clients = clients.ToList(); - _autoFailoverClients = new List(); + _autoFailoverClients = new List(); } - public MockedConfigurationClientManager(IEnumerable clients, IEnumerable autoFailoverClients) + public MockedConfigurationClientManager(IEnumerable clients, IEnumerable autoFailoverClients) { _autoFailoverClients = autoFailoverClients.ToList(); _clients = clients.ToList(); @@ -37,39 +37,22 @@ public bool UpdateSyncToken(Uri endpoint, string syncToken) { this.UpdateSyncTokenCalled++; var client = _clients.SingleOrDefault(c => string.Equals(c.Endpoint.Host, endpoint.Host, StringComparison.OrdinalIgnoreCase)); - client?.Client?.UpdateSyncToken(syncToken); + client?.UpdateSyncToken(syncToken); return true; } - public Uri GetEndpointForClient(ConfigurationClient client) + public IEnumerable GetClients() { - if (client == null) - { - throw new ArgumentNullException(nameof(client)); - } - - ConfigurationClientWrapper currentClient = _clients.FirstOrDefault(c => c.Client == client); - - if (currentClient == null) - { - currentClient = _autoFailoverClients.FirstOrDefault(c => c.Client == client); - } - - return currentClient?.Endpoint; - } - - public IEnumerable GetClients() - { - var result = new List(); + var result = new List(); foreach (var client in _clients) { - result.Add(client.Client); + result.Add(client); } foreach (var client in _autoFailoverClients) { - result.Add(client.Client); + result.Add(client); } return result; diff --git a/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs index 3e69211a9..32753e6c0 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs @@ -1225,6 +1225,8 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) mockClient.Setup(c => c.CheckConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns((Func)GetTestKeys); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { diff --git a/tests/Tests.AzureAppConfiguration/Unit/TagFiltersTests.cs b/tests/Tests.AzureAppConfiguration/Unit/TagFiltersTests.cs index 4ebbffd1d..cf844e529 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/TagFiltersTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/TagFiltersTests.cs @@ -169,6 +169,8 @@ public void TagFiltersTests_BasicTagFiltering() .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { @@ -210,6 +212,8 @@ public void TagFiltersTests_NullOrEmptyValue() kv.Tags.ContainsKey("EmptyTag") && kv.Tags["EmptyTag"] == "" && kv.Tags.ContainsKey("NullTag") && kv.Tags["NullTag"] == null))); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { @@ -251,6 +255,8 @@ public void TagFiltersTests_MultipleTagsFiltering() kv.Tags.ContainsKey("App") && kv.Tags["App"] == "TestApp" && kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { @@ -320,6 +326,8 @@ public void TagFiltersTests_TooManyTags() It.IsAny())) .Throws(new RequestFailedException($"Invalid parameter TagsFilter. Maximum filters is {MaxTagFilters}")); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { @@ -372,6 +380,8 @@ public void TagFiltersTests_TagFilterInteractionWithKeyLabelFilters() kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { @@ -411,6 +421,8 @@ public void TagFiltersTests_EmptyTagsCollection() It.IsAny())) .Returns(new MockAsyncPageable(_kvCollection)); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { @@ -451,6 +463,8 @@ public void TagFiltersTests_SpecialCharactersInTags() .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => kv.Tags.ContainsKey("Special:Tag") && kv.Tags["Special:Tag"] == "Value:With:Colons"))); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { @@ -491,6 +505,8 @@ public void TagFiltersTests_EscapedCommaCharactersInTags() .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => kv.Tags.ContainsKey("Tag,With,Commas") && kv.Tags["Tag,With,Commas"] == "Value,With,Commas"))); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { @@ -537,6 +553,8 @@ public async Task TagFiltersTests_BasicRefresh() kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))) .Returns(mockAsyncPageable); + TestHelpers.SetupMockFeatureFlagEndpoint(mockClient); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { diff --git a/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs b/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs index 437e1171d..58f015d8a 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs @@ -18,6 +18,7 @@ using System.Threading.Tasks; using System.Globalization; using System.Diagnostics; +using System.Runtime.CompilerServices; namespace Tests.AzureAppConfiguration { @@ -26,6 +27,12 @@ class TestHelpers public static readonly Uri PrimaryConfigStoreEndpoint = new Uri("https://azure.azconfig.io"); public static readonly Uri SecondaryConfigStoreEndpoint = new Uri("https://azure---wus.azconfig.io"); + // Associates a mocked ConfigurationClient with the FeatureFlagClient that should be paired with it, + // so that existing tests can keep calling SetupMockFeatureFlagEndpoint(mockClient) before building + // the mocked client manager without having to thread the feature-flag client explicitly. + private static readonly ConditionalWeakTable _featureFlagClients = + new ConditionalWeakTable(); + static public ConfigurationClient CreateMockConfigurationClient(Uri endpoint, AzureAppConfigurationOptions options = null) { var mockTokenCredential = new Mock(); @@ -35,15 +42,15 @@ static public ConfigurationClient CreateMockConfigurationClient(Uri endpoint, Az return new ConfigurationClient(endpoint, mockTokenCredential.Object, options.ClientOptions); } - static public IConfigurationClientManager CreateMockedConfigurationClientManager(AzureAppConfigurationOptions options) + static public IAppConfigurationClientManager CreateMockedConfigurationClientManager(AzureAppConfigurationOptions options) { ConfigurationClient c1 = CreateMockConfigurationClient(PrimaryConfigStoreEndpoint, options); ConfigurationClient c2 = CreateMockConfigurationClient(SecondaryConfigStoreEndpoint, options); - ConfigurationClientWrapper w1 = new ConfigurationClientWrapper(PrimaryConfigStoreEndpoint, c1); - ConfigurationClientWrapper w2 = new ConfigurationClientWrapper(SecondaryConfigStoreEndpoint, c2); + AppConfigurationClient w1 = new AppConfigurationClient(PrimaryConfigStoreEndpoint, c1, CreateMockFeatureFlagClient(PrimaryConfigStoreEndpoint, options)); + AppConfigurationClient w2 = new AppConfigurationClient(SecondaryConfigStoreEndpoint, c2, CreateMockFeatureFlagClient(SecondaryConfigStoreEndpoint, options)); - IList clients = new List() { w1, w2 }; + IList clients = new List() { w1, w2 }; MockedConfigurationClientManager provider = new MockedConfigurationClientManager(clients); @@ -52,10 +59,10 @@ static public IConfigurationClientManager CreateMockedConfigurationClientManager static public MockedConfigurationClientManager CreateMockedConfigurationClientManager(ConfigurationClient primaryClient, ConfigurationClient secondaryClient = null) { - ConfigurationClientWrapper w1 = new ConfigurationClientWrapper(PrimaryConfigStoreEndpoint, primaryClient); - ConfigurationClientWrapper w2 = secondaryClient != null ? new ConfigurationClientWrapper(SecondaryConfigStoreEndpoint, secondaryClient) : null; + AppConfigurationClient w1 = new AppConfigurationClient(PrimaryConfigStoreEndpoint, primaryClient, GetAssociatedFeatureFlagClient(PrimaryConfigStoreEndpoint, primaryClient)); + AppConfigurationClient w2 = secondaryClient != null ? new AppConfigurationClient(SecondaryConfigStoreEndpoint, secondaryClient, GetAssociatedFeatureFlagClient(SecondaryConfigStoreEndpoint, secondaryClient)) : null; - IList clients = new List() { w1 }; + IList clients = new List() { w1 }; if (secondaryClient != null) { @@ -67,6 +74,36 @@ static public MockedConfigurationClientManager CreateMockedConfigurationClientMa return provider; } + static private FeatureFlagClient CreateMockFeatureFlagClient(Uri endpoint, AzureAppConfigurationOptions options) + { + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny())) + .Returns(new ValueTask(new AccessToken("", DateTimeOffset.Now.AddDays(2)))); + + return new FeatureFlagClient(endpoint, mockTokenCredential.Object, options.FeatureFlagClientOptions); + } + + // Creates a real (non-mocked) FeatureFlagClient for tests that don't exercise feature flags but + // still need a non-null feature-flag client to construct an AppConfigurationClient. + static public FeatureFlagClient CreateFeatureFlagClient(Uri endpoint) + { + var mockTokenCredential = new Mock(); + mockTokenCredential.Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny())) + .Returns(new ValueTask(new AccessToken("", DateTimeOffset.Now.AddDays(2)))); + + return new FeatureFlagClient(endpoint, mockTokenCredential.Object, new FeatureFlagClientOptions()); + } + + static private FeatureFlagClient GetAssociatedFeatureFlagClient(Uri endpoint, ConfigurationClient client) + { + if (client != null && _featureFlagClients.TryGetValue(client, out FeatureFlagClient featureFlagClient)) + { + return featureFlagClient; + } + + return CreateFeatureFlagClient(endpoint); + } + static public string CreateMockEndpointString(string endpoint = "https://azure.azconfig.io") { byte[] toEncodeAsBytes = Encoding.ASCII.GetBytes("secret"); @@ -74,6 +111,26 @@ static public string CreateMockEndpointString(string endpoint = "https://azure.a return $"Endpoint={endpoint};Id=b1d9b31;Secret={returnValue}"; } + /// + /// Sets up the standalone feature-flag endpoint to return the supplied feature flags (empty by + /// default) and associates the resulting with the given + /// mock so that the mocked client manager pairs them. Returns the + /// shared pageable so that change detection across reloads is stable. + /// + static public MockFeatureFlagAsyncPageable SetupMockFeatureFlagEndpoint(Mock mockClient, List flags = null) + { + var pageable = new MockFeatureFlagAsyncPageable(flags); + + var mockFeatureFlagClient = new Mock(MockBehavior.Strict); + mockFeatureFlagClient.Setup(c => c.GetFeatureFlagsAsync(It.IsAny(), It.IsAny())) + .Returns(pageable); + + _featureFlagClients.Remove(mockClient.Object); + _featureFlagClients.Add(mockClient.Object, mockFeatureFlagClient.Object); + + return pageable; + } + static public void SerializeSetting(ref Utf8JsonWriter json, ConfigurationSetting setting) { json.WriteStartObject(); @@ -266,6 +323,52 @@ public override async IAsyncEnumerable> AsPages(strin } } + /// + /// A mock for the standalone feature-flag endpoint + /// (). + /// Yields a single page containing the supplied feature flags (empty by default). The page uses a + /// stable ETag so that change detection across reloads reports "no change" unless the collection is + /// explicitly updated via . + /// + class MockFeatureFlagAsyncPageable : AsyncPageable + { + private List _collection; + private string _etag; + private readonly TimeSpan? _delay; + + public MockFeatureFlagAsyncPageable(List collection = null, TimeSpan? delay = null) + { + _collection = collection ?? new List(); + _delay = delay; + _etag = ComputeETag(_collection); + } + + public void UpdateCollection(List newCollection) + { + _collection = newCollection ?? new List(); + _etag = ComputeETag(_collection); + } + + private static string ComputeETag(List collection) + { + // Derive a deterministic ETag from the flag names + enabled state so that an unchanged + // collection keeps the same ETag and a changed collection produces a different one. + string content = string.Join("|", collection.Select(f => $"{f.Name}:{f.Enabled}")); + + return "ff-" + content.GetHashCode().ToString("x8"); + } + + public override async IAsyncEnumerable> AsPages(string continuationToken = null, int? pageSizeHint = null) + { + if (_delay.HasValue) + { + await Task.Delay(_delay.Value); + } + + yield return Page.FromValues(_collection, null, new MockResponse(200, _etag)); + } + } + internal class MockConfigurationSettingPageIterator : IConfigurationSettingPageIterator { public IAsyncEnumerable> IteratePages(AsyncPageable pageable, IEnumerable matchConditions) @@ -278,4 +381,17 @@ public IAsyncEnumerable> IteratePages(AsyncPageable> IteratePages(AsyncPageable pageable) + { + return pageable.AsPages(); + } + + public IAsyncEnumerable> IteratePages(AsyncPageable pageable, IEnumerable matchConditions) + { + return pageable.AsPages(); + } + } }