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"]);
+ }
+ }
+}