diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 6e600fa2a..975f1ab32 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -1,517 +1,537 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Azure.Core; -using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration -{ - /// - /// Options used to configure the behavior of an Azure App Configuration provider. - /// If neither nor is ever called, all key-values with no label are included in the configuration provider. - /// - public class AzureAppConfigurationOptions - { - private const int MaxRetries = 2; - private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); - private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; - - private List _individualKvWatchers = new List(); - private List _ffWatchers = new List(); - private List _adapters; - private List>> _mappers = new List>>(); - private List _selectors; - private IConfigurationRefresher _refresher = new AzureAppConfigurationRefresher(); - private bool _selectCalled = false; - - // The following set is sorted in descending order. - // Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. - private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); - - /// - /// Flag to indicate whether replica discovery is enabled. - /// - public bool ReplicaDiscoveryEnabled { get; set; } = true; - - /// - /// Flag to indicate whether load balancing is enabled. - /// - public bool LoadBalancingEnabled { get; set; } - - /// - /// The list of connection strings used to connect to an Azure App Configuration store and its replicas. - /// - internal IEnumerable ConnectionStrings { get; private set; } - - /// - /// The list of endpoints of an Azure App Configuration store. - /// If this property is set, the property also needs to be set. - /// - internal IEnumerable Endpoints { get; private set; } - - /// - /// The credential used to connect to the Azure App Configuration. - /// If this property is set, the property also needs to be set. - /// - internal TokenCredential Credential { get; private set; } - - /// - /// A collection of specified by user. - /// - internal IEnumerable Selectors => _selectors; - - /// - /// Indicates if was called. - /// - internal bool RegisterAllEnabled { get; private set; } - - /// - /// Refresh interval for selected key-value collections when is called. - /// - internal TimeSpan KvCollectionRefreshInterval { get; private set; } - - /// - /// A collection of . - /// - internal IEnumerable IndividualKvWatchers => _individualKvWatchers; - - /// - /// A collection of . - /// - internal IEnumerable FeatureFlagWatchers => _ffWatchers; - - /// - /// A collection of . - /// - internal IEnumerable Adapters - { - get => _adapters; - set => _adapters = value?.ToList(); - } - - /// - /// A collection of user defined functions that transform each . - /// - internal IEnumerable>> Mappers => _mappers; - - /// - /// A collection of key prefixes to be trimmed. - /// - internal IEnumerable KeyPrefixes => _keyPrefixes; - - /// - /// For use in tests only. An optional configuration client manager that can be used to provide clients to communicate with Azure App Configuration. - /// - internal IConfigurationClientManager ClientManager { get; set; } - - /// - /// For use in tests only. An optional class used to process pageable results from Azure App Configuration. - /// - internal IConfigurationSettingPageIterator ConfigurationSettingPageIterator { get; set; } - - /// - /// An optional timespan value to set the minimum backoff duration to a value other than the default. - /// - internal TimeSpan MinBackoffDuration { get; set; } = FailOverConstants.MinBackoffDuration; - - /// - /// Options used to configure the client used to communicate with Azure App Configuration. - /// - internal ConfigurationClientOptions ClientOptions { get; } = GetDefaultClientOptions(); - - /// - /// Flag to indicate whether Key Vault options have been configured. - /// - internal bool IsKeyVaultConfigured { get; private set; } = false; - - /// - /// Flag to indicate whether Key Vault secret values will be refreshed automatically. - /// - internal bool IsKeyVaultRefreshConfigured { get; private set; } = false; - - /// - /// Indicates all feature flag features used by the application. - /// - internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); - - /// - /// Options used to configure provider startup. - /// - internal StartupOptions Startup { get; set; } = new StartupOptions(); - - /// - /// Initializes a new instance of the class. - /// - public AzureAppConfigurationOptions() - { - _adapters = new List() - { - new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()), - new JsonKeyValueAdapter(), - new FeatureManagementKeyValueAdapter(FeatureFlagTracing) - }; - - // Adds the default query to App Configuration if and are never called. - _selectors = new List { DefaultQuery }; - } - - /// - /// Specify what key-values to include in the configuration provider. - /// can be called multiple times to include multiple sets of key-values. - /// - /// - /// The key filter to apply when querying Azure App Configuration for key-values. - /// An asterisk (*) can be added to the end to return all key-values whose key begins with the key filter. - /// e.g. key filter `abc*` returns all key-values whose key starts with `abc`. - /// A comma (,) can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. - /// Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. - /// E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. - /// For all other cases the characters: asterisk (*), comma (,), and backslash (\) are reserved. Reserved characters must be escaped using a backslash (\). - /// e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. - /// Built-in key filter options: . - /// - /// - /// The label filter to apply when querying Azure App Configuration for key-values. By default the null label will be used. Built-in label filter options: - /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). - /// - public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null) - { - if (string.IsNullOrEmpty(keyFilter)) - { - throw new ArgumentNullException(nameof(keyFilter)); - } - - // Do not support * and , for label filter for now. - if (labelFilter != null && (labelFilter.Contains('*') || labelFilter.Contains(','))) - { - throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); - } - - if (string.IsNullOrWhiteSpace(labelFilter)) - { - labelFilter = LabelFilter.Null; - } - - if (!_selectCalled) - { - _selectors.Remove(DefaultQuery); - - _selectCalled = true; - } - - _selectors.AppendUnique(new KeyValueSelector - { - KeyFilter = keyFilter, - LabelFilter = labelFilter - }); - - return this; - } - - /// - /// Specify a snapshot and include its contained key-values in the configuration provider. - /// can be called multiple times to include key-values from multiple snapshots. - /// - /// The name of the snapshot in Azure App Configuration. - public AzureAppConfigurationOptions SelectSnapshot(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException(nameof(name)); - } - - if (!_selectCalled) - { - _selectors.Remove(DefaultQuery); - - _selectCalled = true; - } - - _selectors.AppendUnique(new KeyValueSelector - { - SnapshotName = name - }); - - return this; - } - - /// - /// Configures options for Azure App Configuration feature flags that will be parsed and transformed into feature management configuration. - /// If no filtering is specified via the then all feature flags with no label are loaded. - /// All loaded feature flags will be automatically registered for refresh as a collection. - /// - /// A callback used to configure feature flag options. - public AzureAppConfigurationOptions UseFeatureFlags(Action configure = null) - { - FeatureFlagOptions options = new FeatureFlagOptions(); - configure?.Invoke(options); - - if (options.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval) - { - throw new ArgumentOutOfRangeException(nameof(options.RefreshInterval), options.RefreshInterval.TotalMilliseconds, - string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds)); - } - - if (options.FeatureFlagSelectors.Count() != 0 && options.Label != null) - { - throw new InvalidOperationException($"Please select feature flags by either the {nameof(options.Select)} method or by setting the {nameof(options.Label)} property, not both."); - } - - if (options.FeatureFlagSelectors.Count() == 0) - { - // Select clause is not present - options.FeatureFlagSelectors.Add(new KeyValueSelector - { - KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", - LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, - IsFeatureFlagSelector = true - }); - } - - foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) - { - _selectors.AppendUnique(featureFlagSelector); - - _ffWatchers.AppendUnique(new KeyValueWatcher - { - Key = featureFlagSelector.KeyFilter, - Label = featureFlagSelector.LabelFilter, - // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins - RefreshInterval = options.RefreshInterval - }); - } - - return this; - } - - /// - /// Connect the provider to the Azure App Configuration service via a connection string. - /// - /// - /// Used to authenticate with Azure App Configuration. - /// - public AzureAppConfigurationOptions Connect(string connectionString) - { - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new ArgumentNullException(nameof(connectionString)); - } - - return Connect(new List { connectionString }); - } - - /// - /// Connect the provider to an Azure App Configuration store and its replicas via a list of connection strings. - /// - /// - /// Used to authenticate with Azure App Configuration. - /// - public AzureAppConfigurationOptions Connect(IEnumerable connectionStrings) - { - if (connectionStrings == null || !connectionStrings.Any()) - { - throw new ArgumentNullException(nameof(connectionStrings)); - } - - if (connectionStrings.Distinct().Count() != connectionStrings.Count()) - { - throw new ArgumentException($"All values in '{nameof(connectionStrings)}' must be unique."); - } - - Endpoints = null; - Credential = null; - ConnectionStrings = connectionStrings; - return this; - } - - /// - /// Connect the provider to Azure App Configuration using endpoint and token credentials. - /// - /// The endpoint of the Azure App Configuration to connect to. - /// Token credentials to use to connect. - public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential credential) - { - if (endpoint == null) - { - throw new ArgumentNullException(nameof(endpoint)); - } - - if (credential == null) - { - throw new ArgumentNullException(nameof(credential)); - } - - return Connect(new List() { endpoint }, credential); - } - - /// - /// Connect the provider to an Azure App Configuration store and its replicas using a list of endpoints and a token credential. - /// - /// The list of endpoints of an Azure App Configuration store and its replicas to connect to. - /// Token credential to use to connect. - public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCredential credential) - { - if (endpoints == null || !endpoints.Any()) - { - throw new ArgumentNullException(nameof(endpoints)); - } - - if (endpoints.Distinct(new EndpointComparer()).Count() != endpoints.Count()) - { - throw new ArgumentException($"All values in '{nameof(endpoints)}' must be unique."); - } - - Credential = credential ?? throw new ArgumentNullException(nameof(credential)); - - Endpoints = endpoints; - ConnectionStrings = null; - return this; - } - - /// - /// Trims the provided prefix from the keys of all key-values retrieved from Azure App Configuration. - /// - /// The prefix to be trimmed. - public AzureAppConfigurationOptions TrimKeyPrefix(string prefix) - { - if (string.IsNullOrEmpty(prefix)) - { - throw new ArgumentNullException(nameof(prefix)); - } - - _keyPrefixes.Add(prefix); - return this; - } - - /// - /// Configure the client(s) used to communicate with Azure App Configuration. - /// - /// A callback used to configure Azure App Configuration client options. - public AzureAppConfigurationOptions ConfigureClientOptions(Action configure) - { - configure?.Invoke(ClientOptions); - return this; - } - - /// - /// Configure refresh for key-values in the configuration provider. - /// - /// A callback used to configure Azure App Configuration refresh options. - public AzureAppConfigurationOptions ConfigureRefresh(Action configure) - { - if (RegisterAllEnabled) - { - throw new InvalidOperationException($"{nameof(ConfigureRefresh)}() cannot be invoked multiple times when {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} has been invoked."); - } - - var refreshOptions = new AzureAppConfigurationRefreshOptions(); - configure?.Invoke(refreshOptions); - - bool isRegisterCalled = refreshOptions.RefreshRegistrations.Any(); - RegisterAllEnabled = refreshOptions.RegisterAllEnabled; - - if (!isRegisterCalled && !RegisterAllEnabled) - { - throw new InvalidOperationException($"{nameof(ConfigureRefresh)}() must call either {nameof(AzureAppConfigurationRefreshOptions.Register)}()" + - $" or {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)}()"); - } - - // Check if both register methods are called at any point - if (RegisterAllEnabled && (_individualKvWatchers.Any() || isRegisterCalled)) - { - throw new InvalidOperationException($"Cannot call both {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} and " - + $"{nameof(AzureAppConfigurationRefreshOptions.Register)}."); - } - - if (RegisterAllEnabled) - { - KvCollectionRefreshInterval = refreshOptions.RefreshInterval; - } - else - { - foreach (KeyValueWatcher item in refreshOptions.RefreshRegistrations) - { - item.RefreshInterval = refreshOptions.RefreshInterval; - _individualKvWatchers.Add(item); - } - } - - return this; - } - - /// - /// Get an instance of that can be used to trigger a refresh for the registered key-values. - /// - /// An instance of . - public IConfigurationRefresher GetRefresher() - { - return _refresher; - } - - /// - /// Configures the Azure App Configuration provider to use the provided Key Vault configuration to resolve key vault references. - /// - /// A callback used to configure Azure App Configuration key vault options. - public AzureAppConfigurationOptions ConfigureKeyVault(Action configure) - { - var keyVaultOptions = new AzureAppConfigurationKeyVaultOptions(); - configure?.Invoke(keyVaultOptions); - - if (keyVaultOptions.Credential != null && keyVaultOptions.SecretResolver != null) - { - throw new InvalidOperationException($"Cannot configure both default credentials and secret resolver for Key Vault references. Please call either {nameof(keyVaultOptions.SetCredential)} or {nameof(keyVaultOptions.SetSecretResolver)} method, not both."); - } - - _adapters.RemoveAll(a => a is AzureKeyVaultKeyValueAdapter); - _adapters.Add(new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider(keyVaultOptions))); - - IsKeyVaultRefreshConfigured = keyVaultOptions.IsKeyVaultRefreshConfigured; - IsKeyVaultConfigured = true; - return this; - } - - /// - /// Provides a way to transform settings retrieved from App Configuration before they are processed by the configuration provider. - /// - /// A callback registered by the user to transform each configuration setting. - public AzureAppConfigurationOptions Map(Func> mapper) - { - if (mapper == null) - { - throw new ArgumentNullException(nameof(mapper)); - } - - _mappers.Add(mapper); - return this; - } - - /// - /// Configure the provider behavior when loading data from Azure App Configuration on startup. - /// - /// A callback used to configure Azure App Configuration startup options. - public AzureAppConfigurationOptions ConfigureStartupOptions(Action configure) - { - configure?.Invoke(Startup); - return this; - } - - private static ConfigurationClientOptions GetDefaultClientOptions() - { - var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_10_01); - clientOptions.Retry.MaxRetries = MaxRetries; - clientOptions.Retry.MaxDelay = MaxRetryDelay; - clientOptions.Retry.Mode = RetryMode.Exponential; - clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); - - return clientOptions; - } - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Core; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + /// + /// Options used to configure the behavior of an Azure App Configuration provider. + /// If neither nor is ever called, all key-values with no label are included in the configuration provider. + /// + public class AzureAppConfigurationOptions + { + private const int MaxRetries = 2; + private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); + private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; + + private List _individualKvWatchers = new List(); + private List _ffWatchers = new List(); + private List _adapters; + private List>> _mappers = new List>>(); + private List _selectors; + private IConfigurationRefresher _refresher = new AzureAppConfigurationRefresher(); + private bool _selectCalled = false; + + // The following set is sorted in descending order. + // Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. + private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); + + /// + /// Flag to indicate whether replica discovery is enabled. + /// + public bool ReplicaDiscoveryEnabled { get; set; } = true; + + /// + /// Flag to indicate whether load balancing is enabled. + /// + public bool LoadBalancingEnabled { get; set; } + + /// + /// The list of connection strings used to connect to an Azure App Configuration store and its replicas. + /// + internal IEnumerable ConnectionStrings { get; private set; } + + /// + /// The list of endpoints of an Azure App Configuration store. + /// If this property is set, the property also needs to be set. + /// + internal IEnumerable Endpoints { get; private set; } + + /// + /// The credential used to connect to the Azure App Configuration. + /// If this property is set, the property also needs to be set. + /// + internal TokenCredential Credential { get; private set; } + + /// + /// A collection of specified by user. + /// + internal IEnumerable Selectors => _selectors; + + /// + /// Indicates if was called. + /// + internal bool RegisterAllEnabled { get; private set; } + + /// + /// Refresh interval for selected key-value collections when is called. + /// + internal TimeSpan KvCollectionRefreshInterval { get; private set; } + + /// + /// A collection of . + /// + internal IEnumerable IndividualKvWatchers => _individualKvWatchers; + + /// + /// A collection of . + /// + internal IEnumerable FeatureFlagWatchers => _ffWatchers; + + /// + /// A collection of . + /// + internal IEnumerable Adapters + { + get => _adapters; + set => _adapters = value?.ToList(); + } + + /// + /// A collection of user defined functions that transform each . + /// + internal IEnumerable>> Mappers => _mappers; + + /// + /// A collection of key prefixes to be trimmed. + /// + internal IEnumerable KeyPrefixes => _keyPrefixes; + + /// + /// For use in tests only. An optional configuration client manager that can be used to provide clients to communicate with Azure App Configuration. + /// + internal IConfigurationClientManager ClientManager { get; set; } + + /// + /// For use in tests only. An optional class used to process pageable results from Azure App Configuration. + /// + internal IConfigurationSettingPageIterator ConfigurationSettingPageIterator { get; set; } + + /// + /// An optional timespan value to set the minimum backoff duration to a value other than the default. + /// + internal TimeSpan MinBackoffDuration { get; set; } = FailOverConstants.MinBackoffDuration; + + /// + /// Options used to configure the client used to communicate with Azure App Configuration. + /// + internal ConfigurationClientOptions ClientOptions { get; } = GetDefaultClientOptions(); + + /// + /// Flag to indicate whether Key Vault options have been configured. + /// + internal bool IsKeyVaultConfigured { get; private set; } = false; + + /// + /// Flag to indicate whether Key Vault secret values will be refreshed automatically. + /// + internal bool IsKeyVaultRefreshConfigured { get; private set; } = false; + + /// + /// Indicates all feature flag features used by the application. + /// + internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); + + /// + /// Options used to configure provider startup. + /// + internal StartupOptions Startup { get; set; } = new StartupOptions(); + + /// + /// Initializes a new instance of the class. + /// + public AzureAppConfigurationOptions() + { + _adapters = new List() + { + new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()), + new JsonKeyValueAdapter(), + new FeatureManagementKeyValueAdapter(FeatureFlagTracing) + }; + + // Adds the default query to App Configuration if and are never called. + _selectors = new List { DefaultQuery }; + } + + /// + /// Specify what key-values to include in the configuration provider. + /// can be called multiple times to include multiple sets of key-values. + /// + /// + /// The key filter to apply when querying Azure App Configuration for key-values. + /// An asterisk (*) can be added to the end to return all key-values whose key begins with the key filter. + /// e.g. key filter `abc*` returns all key-values whose key starts with `abc`. + /// A comma (,) can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. + /// Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. + /// E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. + /// For all other cases the characters: asterisk (*), comma (,), and backslash (\) are reserved. Reserved characters must be escaped using a backslash (\). + /// e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. + /// Built-in key filter options: . + /// + /// + /// The label filter to apply when querying Azure App Configuration for key-values. By default the null label will be used. Built-in label filter options: + /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). + /// + /// + /// In addition to key and label filters, key-values from Azure App Configuration can be filtered based on their tag names and values. + /// Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. + /// Built in tag filter values: . For example, $"tagName={}". + /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). + /// Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. + /// + public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable tagFilters = null) + { + if (string.IsNullOrEmpty(keyFilter)) + { + throw new ArgumentNullException(nameof(keyFilter)); + } + + // Do not support * and , for label filter for now. + if (labelFilter != null && (labelFilter.Contains('*') || labelFilter.Contains(','))) + { + throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); + } + + if (string.IsNullOrWhiteSpace(labelFilter)) + { + labelFilter = LabelFilter.Null; + } + + if (tagFilters != null) + { + foreach (string tag in tagFilters) + { + if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) + { + throw new ArgumentException($"Tag filter '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagFilters)); + } + } + } + + if (!_selectCalled) + { + _selectors.Remove(DefaultQuery); + + _selectCalled = true; + } + + _selectors.AppendUnique(new KeyValueSelector + { + KeyFilter = keyFilter, + LabelFilter = labelFilter, + TagFilters = tagFilters + }); + + return this; + } + + /// + /// Specify a snapshot and include its contained key-values in the configuration provider. + /// can be called multiple times to include key-values from multiple snapshots. + /// + /// The name of the snapshot in Azure App Configuration. + public AzureAppConfigurationOptions SelectSnapshot(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + if (!_selectCalled) + { + _selectors.Remove(DefaultQuery); + + _selectCalled = true; + } + + _selectors.AppendUnique(new KeyValueSelector + { + SnapshotName = name + }); + + return this; + } + + /// + /// Configures options for Azure App Configuration feature flags that will be parsed and transformed into feature management configuration. + /// If no filtering is specified via the then all feature flags with no label are loaded. + /// All loaded feature flags will be automatically registered for refresh as a collection. + /// + /// A callback used to configure feature flag options. + public AzureAppConfigurationOptions UseFeatureFlags(Action configure = null) + { + FeatureFlagOptions options = new FeatureFlagOptions(); + configure?.Invoke(options); + + if (options.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval) + { + throw new ArgumentOutOfRangeException(nameof(options.RefreshInterval), options.RefreshInterval.TotalMilliseconds, + string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds)); + } + + if (options.FeatureFlagSelectors.Count() != 0 && options.Label != null) + { + throw new InvalidOperationException($"Please select feature flags by either the {nameof(options.Select)} method or by setting the {nameof(options.Label)} property, not both."); + } + + if (options.FeatureFlagSelectors.Count() == 0) + { + // Select clause is not present + options.FeatureFlagSelectors.Add(new KeyValueSelector + { + KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", + LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, + IsFeatureFlagSelector = true + }); + } + + foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) + { + _selectors.AppendUnique(featureFlagSelector); + + _ffWatchers.AppendUnique(new KeyValueWatcher + { + Key = featureFlagSelector.KeyFilter, + Label = featureFlagSelector.LabelFilter, + Tags = featureFlagSelector.TagFilters, + // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins + RefreshInterval = options.RefreshInterval + }); + } + + return this; + } + + /// + /// Connect the provider to the Azure App Configuration service via a connection string. + /// + /// + /// Used to authenticate with Azure App Configuration. + /// + public AzureAppConfigurationOptions Connect(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + return Connect(new List { connectionString }); + } + + /// + /// Connect the provider to an Azure App Configuration store and its replicas via a list of connection strings. + /// + /// + /// Used to authenticate with Azure App Configuration. + /// + public AzureAppConfigurationOptions Connect(IEnumerable connectionStrings) + { + if (connectionStrings == null || !connectionStrings.Any()) + { + throw new ArgumentNullException(nameof(connectionStrings)); + } + + if (connectionStrings.Distinct().Count() != connectionStrings.Count()) + { + throw new ArgumentException($"All values in '{nameof(connectionStrings)}' must be unique."); + } + + Endpoints = null; + Credential = null; + ConnectionStrings = connectionStrings; + return this; + } + + /// + /// Connect the provider to Azure App Configuration using endpoint and token credentials. + /// + /// The endpoint of the Azure App Configuration to connect to. + /// Token credentials to use to connect. + public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential credential) + { + if (endpoint == null) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + if (credential == null) + { + throw new ArgumentNullException(nameof(credential)); + } + + return Connect(new List() { endpoint }, credential); + } + + /// + /// Connect the provider to an Azure App Configuration store and its replicas using a list of endpoints and a token credential. + /// + /// The list of endpoints of an Azure App Configuration store and its replicas to connect to. + /// Token credential to use to connect. + public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCredential credential) + { + if (endpoints == null || !endpoints.Any()) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + if (endpoints.Distinct(new EndpointComparer()).Count() != endpoints.Count()) + { + throw new ArgumentException($"All values in '{nameof(endpoints)}' must be unique."); + } + + Credential = credential ?? throw new ArgumentNullException(nameof(credential)); + + Endpoints = endpoints; + ConnectionStrings = null; + return this; + } + + /// + /// Trims the provided prefix from the keys of all key-values retrieved from Azure App Configuration. + /// + /// The prefix to be trimmed. + public AzureAppConfigurationOptions TrimKeyPrefix(string prefix) + { + if (string.IsNullOrEmpty(prefix)) + { + throw new ArgumentNullException(nameof(prefix)); + } + + _keyPrefixes.Add(prefix); + return this; + } + + /// + /// Configure the client(s) used to communicate with Azure App Configuration. + /// + /// A callback used to configure Azure App Configuration client options. + public AzureAppConfigurationOptions ConfigureClientOptions(Action configure) + { + configure?.Invoke(ClientOptions); + return this; + } + + /// + /// Configure refresh for key-values in the configuration provider. + /// + /// A callback used to configure Azure App Configuration refresh options. + public AzureAppConfigurationOptions ConfigureRefresh(Action configure) + { + if (RegisterAllEnabled) + { + throw new InvalidOperationException($"{nameof(ConfigureRefresh)}() cannot be invoked multiple times when {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} has been invoked."); + } + + var refreshOptions = new AzureAppConfigurationRefreshOptions(); + configure?.Invoke(refreshOptions); + + bool isRegisterCalled = refreshOptions.RefreshRegistrations.Any(); + RegisterAllEnabled = refreshOptions.RegisterAllEnabled; + + if (!isRegisterCalled && !RegisterAllEnabled) + { + throw new InvalidOperationException($"{nameof(ConfigureRefresh)}() must call either {nameof(AzureAppConfigurationRefreshOptions.Register)}()" + + $" or {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)}()"); + } + + // Check if both register methods are called at any point + if (RegisterAllEnabled && (_individualKvWatchers.Any() || isRegisterCalled)) + { + throw new InvalidOperationException($"Cannot call both {nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} and " + + $"{nameof(AzureAppConfigurationRefreshOptions.Register)}."); + } + + if (RegisterAllEnabled) + { + KvCollectionRefreshInterval = refreshOptions.RefreshInterval; + } + else + { + foreach (KeyValueWatcher item in refreshOptions.RefreshRegistrations) + { + item.RefreshInterval = refreshOptions.RefreshInterval; + _individualKvWatchers.Add(item); + } + } + + return this; + } + + /// + /// Get an instance of that can be used to trigger a refresh for the registered key-values. + /// + /// An instance of . + public IConfigurationRefresher GetRefresher() + { + return _refresher; + } + + /// + /// Configures the Azure App Configuration provider to use the provided Key Vault configuration to resolve key vault references. + /// + /// A callback used to configure Azure App Configuration key vault options. + public AzureAppConfigurationOptions ConfigureKeyVault(Action configure) + { + var keyVaultOptions = new AzureAppConfigurationKeyVaultOptions(); + configure?.Invoke(keyVaultOptions); + + if (keyVaultOptions.Credential != null && keyVaultOptions.SecretResolver != null) + { + throw new InvalidOperationException($"Cannot configure both default credentials and secret resolver for Key Vault references. Please call either {nameof(keyVaultOptions.SetCredential)} or {nameof(keyVaultOptions.SetSecretResolver)} method, not both."); + } + + _adapters.RemoveAll(a => a is AzureKeyVaultKeyValueAdapter); + _adapters.Add(new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider(keyVaultOptions))); + + IsKeyVaultRefreshConfigured = keyVaultOptions.IsKeyVaultRefreshConfigured; + IsKeyVaultConfigured = true; + return this; + } + + /// + /// Provides a way to transform settings retrieved from App Configuration before they are processed by the configuration provider. + /// + /// A callback registered by the user to transform each configuration setting. + public AzureAppConfigurationOptions Map(Func> mapper) + { + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + _mappers.Add(mapper); + return this; + } + + /// + /// Configure the provider behavior when loading data from Azure App Configuration on startup. + /// + /// A callback used to configure Azure App Configuration startup options. + public AzureAppConfigurationOptions ConfigureStartupOptions(Action configure) + { + configure?.Invoke(Startup); + return this; + } + + private static ConfigurationClientOptions GetDefaultClientOptions() + { + var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_10_01); + clientOptions.Retry.MaxRetries = MaxRetries; + clientOptions.Retry.MaxDelay = MaxRetryDelay; + clientOptions.Retry.Mode = RetryMode.Exponential; + clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); + + return clientOptions; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index d1e18edb4..28e2d507d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -346,6 +346,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { KeyFilter = watcher.Key, LabelFilter = watcher.Label, + TagFilters = watcher.Tags, IsFeatureFlagSelector = true }), _ffEtags, @@ -828,6 +829,14 @@ private async Task> LoadSelected( LabelFilter = loadOption.LabelFilter }; + if (loadOption.TagFilters != null) + { + foreach (string tagFilter in loadOption.TagFilters) + { + selector.TagsFilter.Add(tagFilter); + } + } + var matchConditions = new List(); await CallWithRequestTracing(async () => diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 263907624..4b6d56d6d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -74,7 +74,14 @@ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) /// The label filter to apply when querying Azure App Configuration for feature flags. By default the null label will be used. Built-in label filter options: /// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\). /// - public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null) + /// + /// In addition to key and label filters, feature flags from Azure App Configuration can be filtered based on their tag names and values. + /// Each tag filter must follow the format "tagName=tagValue". Only those feature flags will be loaded whose tags match all the tags provided here. + /// Built in tag filter values: . For example, $"tagName={}". + /// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\). + /// Up to 5 tag filters can be provided. If no tag filters are provided, feature flags will not be filtered based on tags. + /// + public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null, IEnumerable tagFilters = null) { if (string.IsNullOrEmpty(featureFlagFilter)) { @@ -97,12 +104,24 @@ public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); } + if (tagFilters != null) + { + foreach (string tag in tagFilters) + { + if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0) + { + throw new ArgumentException($"Tag filter '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagFilters)); + } + } + } + string featureFlagPrefix = FeatureManagementConstants.FeatureFlagMarker + featureFlagFilter; FeatureFlagSelectors.AppendUnique(new KeyValueSelector { KeyFilter = featureFlagPrefix, LabelFilter = labelFilter, + TagFilters = tagFilters, IsFeatureFlagSelector = true }); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index d5af2b14b..b83cf2e27 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -15,10 +15,12 @@ - + + + diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs index 54bda1a49..f01eb6559 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System; +using System.Collections.Generic; +using System.Linq; + namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Models { /// @@ -24,6 +28,11 @@ public class KeyValueSelector /// public string SnapshotName { get; set; } + /// + /// A filter that determines what tags to require when selecting key-values for the the configuration provider. + /// + public IEnumerable TagFilters { get; set; } + /// /// A boolean that signifies whether this selector is intended to select feature flags. /// @@ -40,7 +49,11 @@ public override bool Equals(object obj) { return KeyFilter == selector.KeyFilter && LabelFilter == selector.LabelFilter - && SnapshotName == selector.SnapshotName; + && SnapshotName == selector.SnapshotName + && (TagFilters == null + ? selector.TagFilters == null + : selector.TagFilters != null && new HashSet(TagFilters).SetEquals(selector.TagFilters)) + && IsFeatureFlagSelector == selector.IsFeatureFlagSelector; } return false; @@ -52,9 +65,22 @@ public override bool Equals(object obj) /// A hash code for the current object. public override int GetHashCode() { - return (KeyFilter?.GetHashCode() ?? 0) ^ - (LabelFilter?.GetHashCode() ?? 1) ^ - (SnapshotName?.GetHashCode() ?? 2); + string tagFiltersString = string.Empty; + + if (TagFilters != null && TagFilters.Any()) + { + var sortedTags = new SortedSet(TagFilters); + + // Concatenate tags into a single string with a delimiter + tagFiltersString = string.Join("\n", sortedTags); + } + + return HashCode.Combine( + KeyFilter, + LabelFilter, + SnapshotName, + tagFiltersString, + IsFeatureFlagSelector); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs index 616f8bcd1..a9f59e745 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs @@ -3,6 +3,7 @@ // using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using System; +using System.Collections.Generic; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Models { @@ -18,6 +19,11 @@ internal class KeyValueWatcher /// public string Label { get; set; } + /// + /// Tags of the key-value to be watched. + /// + public IEnumerable Tags { get; set; } + /// /// A flag to refresh all key-values. /// diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs new file mode 100644 index 000000000..7522e7e13 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TagValue.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + /// + /// Defines well known tag values that are used within Azure App Configuration. + /// + public class TagValue + { + /// + /// Matches null tag values. + /// + public const string Null = "\0"; + } +} diff --git a/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs b/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs new file mode 100644 index 000000000..f0e4e2636 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs @@ -0,0 +1,629 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using Moq; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Tests.AzureAppConfiguration +{ + public class TagFiltersTests + { + private List _kvCollection; + private const int MaxTagFilters = 5; + + public TagFiltersTests() + { + _kvCollection = new List + { + CreateConfigurationSetting("TestKey1", "label", "TestValue1", "0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63", + new Dictionary { + { "Environment", "Development" }, + { "App", "TestApp" } + }), + + CreateConfigurationSetting("TestKey2", "label", "TestValue2", "31c38369-831f-4bf1-b9ad-79db56c8b989", + new Dictionary { + { "Environment", "Production" }, + { "App", "TestApp" } + }), + + CreateConfigurationSetting("TestKey3", "label", "TestValue3", "bb203f2b-c113-44fc-995d-b933c2143339", + new Dictionary { + { "Environment", "Development" }, + { "Component", "API" } + }), + + CreateConfigurationSetting("TestKey4", "label", "TestValue4", "bb203f2b-c113-44fc-995d-b933c2143340", + new Dictionary { + { "Environment", "Staging" }, + { "App", "TestApp" }, + { "Component", "Frontend" } + }), + + CreateConfigurationSetting("TestKey5", "label", "TestValue5", "bb203f2b-c113-44fc-995d-b933c2143341", + new Dictionary { + { "Special:Tag", "Value:With:Colons" }, + { "Tag@With@At", "Value@With@At" } + }), + + CreateConfigurationSetting("TestKey6", "label", "TestValue6", "bb203f2b-c113-44fc-995d-b933c2143342", + new Dictionary { + { "Tag,With,Commas", "Value,With,Commas" }, + { "Simple", "Tag" }, + { "EmptyTag", "" }, + { "NullTag", null } + }), + + CreateFeatureFlagSetting("Feature1", "label", true, "0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63", + new Dictionary { + { "Environment", "Development" }, + { "App", "TestApp" } + }), + + CreateFeatureFlagSetting("Feature2", "label", false, "31c38369-831f-4bf1-b9ad-79db56c8b989", + new Dictionary { + { "Environment", "Production" }, + { "App", "TestApp" } + }), + + CreateFeatureFlagSetting("Feature3", "label", true, "bb203f2b-c113-44fc-995d-b933c2143339", + new Dictionary { + { "Environment", "Development" }, + { "Component", "API" } + }), + + CreateFeatureFlagSetting("Feature4", "label", false, "bb203f2b-c113-44fc-995d-b933c2143340", + new Dictionary { + { "Environment", "Staging" }, + { "App", "TestApp" }, + { "Component", "Frontend" } + }), + + CreateFeatureFlagSetting("Feature5", "label", true, "bb203f2b-c113-44fc-995d-b933c2143341", + new Dictionary { + { "Special:Tag", "Value:With:Colons" }, + { "Tag@With@At", "Value@With@At" } + }), + + CreateFeatureFlagSetting("Feature6", "label", false, "bb203f2b-c113-44fc-995d-b933c2143342", + new Dictionary { + { "Tag,With,Commas", "Value,With,Commas" }, + { "Simple", "Tag" }, + { "EmptyTag", "" }, + { "NullTag", null } + }), + }; + } + + private ConfigurationSetting CreateConfigurationSetting(string key, string label, string value, string etag, IDictionary tags) + { + // Create the setting without tags + var setting = ConfigurationModelFactory.ConfigurationSetting( + key: key, + label: label, + value: value, + eTag: new ETag(etag), + contentType: "text"); + + // Add tags to the setting + if (tags != null) + { + foreach (var tag in tags) + { + setting.Tags.Add(tag.Key, tag.Value); + } + } + + return setting; + } + + private ConfigurationSetting CreateFeatureFlagSetting(string featureId, string label, bool enabled, string etag, IDictionary tags) + { + string jsonValue = $@" + {{ + ""id"": ""{featureId}"", + ""description"": ""Test feature flag"", + ""enabled"": {enabled.ToString().ToLowerInvariant()}, + ""conditions"": {{ + ""client_filters"": [] + }} + }}"; + + // Create the feature flag setting + var setting = ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + featureId, + label: label, + value: jsonValue, + eTag: new ETag(etag), + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8"); + + // Add tags to the setting + if (tags != null) + { + foreach (var tag in tags) + { + setting.Tags.Add(tag.Key, tag.Value); + } + } + + return setting; + } + + [Fact] + public void TagFiltersTests_BasicTagFiltering() + { + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains("Environment=Development")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + }); + }) + .Build(); + + // Only TestKey1 and TestKey3 have Environment=Development tag + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + + Assert.NotNull(config["FeatureManagement:Feature1"]); + Assert.NotNull(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); + } + + [Fact] + public void TagFiltersTests_NullOrEmptyValue() + { + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains("EmptyTag=") && + s.TagsFilter.Contains($"NullTag={TagValue.Null}")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("EmptyTag") && kv.Tags["EmptyTag"] == "" && + kv.Tags.ContainsKey("NullTag") && kv.Tags["NullTag"] == null))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "EmptyTag=", $"NullTag={TagValue.Null}" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "EmptyTag=", $"NullTag={TagValue.Null}" }); + }); + }) + .Build(); + + // Only TestKey6 and Feature6 have EmptyTag and NullTag + Assert.Null(config["TestKey1"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey3"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Equal("TestValue6", config["TestKey6"]); + + Assert.Null(config["FeatureManagement:Feature1"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.NotNull(config["FeatureManagement:Feature6"]); + } + + [Fact] + public void TagFiltersTests_MultipleTagsFiltering() + { + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains("App=TestApp") && + s.TagsFilter.Contains("Environment=Development")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("App") && kv.Tags["App"] == "TestApp" && + kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "App=TestApp", "Environment=Development" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "App=TestApp", "Environment=Development" }); + }); + }) + .Build(); + + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey3"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + + Assert.NotNull(config["FeatureManagement:Feature1"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); + } + + [Fact] + public void TagFiltersTests_InvalidTagFormat() + { + var mockClient = new Mock(MockBehavior.Strict); + + List invalidTagFilters = new List { "InvalidTagFormat", "=tagValue", "", null }; + + foreach (string tagsFilter in invalidTagFilters) + { + // Verify that an ArgumentException is thrown when using an invalid tag format + var exception = Assert.Throws(() => + { + new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { tagsFilter }); + }) + .Build(); + }); + + Assert.Contains($"Tag filter '{tagsFilter}' does not follow the format \"tagName=tagValue\".", exception.Message); + } + } + + [Fact] + public void TagFiltersTests_TooManyTags() + { + var mockClient = new Mock(MockBehavior.Strict); + var mockResponse = new Mock(); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains("Environment=Development") && s.TagsFilter.Count <= MaxTagFilters), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Count > MaxTagFilters), + It.IsAny())) + .Throws(new RequestFailedException($"Invalid parameter TagsFilter. Maximum filters is {MaxTagFilters}")); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + }); + }) + .Build(); + + List longTagsFilter = new List + { + "Environment=Development", + "Environment=Development", + "Environment=Development", + "Environment=Development", + "Environment=Development", + "Environment=Development" + }; + + // Verify that a RequestFailedException is thrown when passing more than the allowed number of tags + var exception = Assert.Throws(() => + { + new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", longTagsFilter); + }) + .Build(); + }); + } + + [Fact] + public void TagFiltersTests_TagFilterInteractionWithKeyLabelFilters() + { + var mockClient = new Mock(MockBehavior.Strict); + + // Setup mock to verify that all three filters (key, label, tags) are correctly applied together + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + (s.KeyFilter == "TestKey*" || s.KeyFilter == FeatureManagementConstants.FeatureFlagMarker + "Feature1") && + s.LabelFilter == "label" && + s.TagsFilter.Contains("Environment=Development")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + (kv.Key.StartsWith("TestKey") || kv.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker + "Feature1")) && + kv.Label == "label" && + kv.Tags.ContainsKey("Environment") && + kv.Tags["Environment"] == "Development"))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select("TestKey*", "label", new List { "Environment=Development" }); + options.UseFeatureFlags(ff => + { + ff.Select("Feature1", "label", new List { "Environment=Development" }); + }); + }) + .Build(); + + // Only TestKey1 and TestKey3 match all criteria + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + + Assert.NotNull(config["FeatureManagement:Feature1"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); + } + + [Fact] + public void TagFiltersTests_EmptyTagsCollection() + { + var mockClient = new Mock(MockBehavior.Strict); + + // Setup mock to verify behavior with empty tags collection + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Count == 0), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List()); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List()); + }); + }) + .Build(); + + // All keys should be returned when no tag filtering is applied + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Equal("TestValue2", config["TestKey2"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Equal("TestValue4", config["TestKey4"]); + Assert.Equal("TestValue5", config["TestKey5"]); + Assert.Equal("TestValue6", config["TestKey6"]); + + Assert.NotNull(config["FeatureManagement:Feature1"]); + Assert.NotNull(config["FeatureManagement:Feature2"]); + Assert.NotNull(config["FeatureManagement:Feature3"]); + Assert.NotNull(config["FeatureManagement:Feature4"]); + Assert.NotNull(config["FeatureManagement:Feature5"]); + Assert.NotNull(config["FeatureManagement:Feature6"]); + } + + [Fact] + public void TagFiltersTests_SpecialCharactersInTags() + { + var mockClient = new Mock(MockBehavior.Strict); + + // Setup mock for special characters in tags + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains("Special:Tag=Value:With:Colons")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("Special:Tag") && kv.Tags["Special:Tag"] == "Value:With:Colons"))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "Special:Tag=Value:With:Colons" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "Special:Tag=Value:With:Colons" }); + }); + }) + .Build(); + + // Only TestKey5 has the special character tag + Assert.Equal("TestValue5", config["TestKey5"]); + Assert.Null(config["TestKey1"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey3"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey6"]); + + Assert.NotNull(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature1"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature6"]); + } + + [Fact] + public void TagFiltersTests_EscapedCommaCharactersInTags() + { + var mockClient = new Mock(MockBehavior.Strict); + + // Setup mock for comma characters in tags that need to be escaped with backslash + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.Is(s => + s.TagsFilter.Contains(@"Tag\,With\,Commas=Value\,With\,Commas")), + It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("Tag,With,Commas") && kv.Tags["Tag,With,Commas"] == "Value,With,Commas"))); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { @"Tag\,With\,Commas=Value\,With\,Commas" }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { @"Tag\,With\,Commas=Value\,With\,Commas" }); + }); + }) + .Build(); + + // Only TestKey6 has the tag with commas + Assert.Equal("TestValue6", config["TestKey6"]); + Assert.Null(config["TestKey1"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey3"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + + Assert.NotNull(config["FeatureManagement:Feature6"]); + Assert.Null(config["FeatureManagement:Feature1"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + } + + [Fact] + public async Task TagFiltersTests_BasicRefresh() + { + var mockClient = new Mock(MockBehavior.Strict); + IConfigurationRefresher refresher = null; + + var mockAsyncPageable = new MockAsyncPageable(_kvCollection); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Callback(() => mockAsyncPageable.UpdateCollection(_kvCollection.FindAll(kv => + kv.Tags.ContainsKey("Environment") && kv.Tags["Environment"] == "Development"))) + .Returns(mockAsyncPageable); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.RegisterAll(); + refreshOptions.SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + options.UseFeatureFlags(ff => + { + ff.Select(KeyFilter.Any, "label", new List { "Environment=Development" }); + ff.SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + // Only TestKey1 and TestKey3 have Environment=Development tag + Assert.Equal("TestValue1", config["TestKey1"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + + Assert.Equal("True", config["FeatureManagement:Feature1"]); + Assert.NotNull(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); + + _kvCollection.Find(setting => setting.Key == "TestKey1").Value = "UpdatedValue1"; + + _kvCollection.Find(setting => setting.Key == FeatureManagementConstants.FeatureFlagMarker + "Feature1").Value = $@" + {{ + ""id"": ""Feature1"", + ""description"": ""Test feature flag"", + ""enabled"": false, + ""conditions"": {{ + ""client_filters"": [] + }} + }}"; + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("UpdatedValue1", config["TestKey1"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + + Assert.Equal("False", config["FeatureManagement:Feature1"]); + Assert.NotNull(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); + + _kvCollection.Find(setting => setting.Key == FeatureManagementConstants.FeatureFlagMarker + "Feature1").Value = $@" + {{ + ""id"": ""Feature1"", + ""description"": ""Test feature flag"", + ""enabled"": true, + ""conditions"": {{ + ""client_filters"": [] + }} + }}"; + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("UpdatedValue1", config["TestKey1"]); + Assert.Equal("TestValue3", config["TestKey3"]); + Assert.Null(config["TestKey2"]); + Assert.Null(config["TestKey4"]); + Assert.Null(config["TestKey5"]); + Assert.Null(config["TestKey6"]); + + Assert.Equal("True", config["FeatureManagement:Feature1"]); + Assert.NotNull(config["FeatureManagement:Feature3"]); + Assert.Null(config["FeatureManagement:Feature2"]); + Assert.Null(config["FeatureManagement:Feature4"]); + Assert.Null(config["FeatureManagement:Feature5"]); + Assert.Null(config["FeatureManagement:Feature6"]); + } + } +}