diff --git a/build/install-dotnet.ps1 b/build/install-dotnet.ps1
index 194dbf5a..eeb52b70 100644
--- a/build/install-dotnet.ps1
+++ b/build/install-dotnet.ps1
@@ -1,10 +1,8 @@
-# Installs .NET 6, .NET 7, and .NET 8 for CI/CD environment
+# Installs .NET 6 and .NET 8 for CI/CD environment
# see: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script#examples
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12;
&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 6.0
-&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 7.0
-
&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 8.0
diff --git a/examples/ConfigStoreDemo/Program.cs b/examples/ConfigStoreDemo/Program.cs
index 90838b59..40d0b9ed 100644
--- a/examples/ConfigStoreDemo/Program.cs
+++ b/examples/ConfigStoreDemo/Program.cs
@@ -30,7 +30,7 @@ public static IWebHost BuildWebHost(string[] args)
.ConfigureRefresh(refresh =>
{
refresh.Register("Settings:BackgroundColor")
- .SetCacheExpiration(TimeSpan.FromSeconds(10));
+ .SetRefreshInterval(TimeSpan.FromSeconds(10));
});
});
})
diff --git a/examples/ConsoleApplication/Program.cs b/examples/ConsoleApplication/Program.cs
index a3e371b0..c3d52ac8 100644
--- a/examples/ConsoleApplication/Program.cs
+++ b/examples/ConsoleApplication/Program.cs
@@ -57,7 +57,7 @@ private static void Configure()
{
refresh.Register("AppName")
.Register("Language", refreshAll: true)
- .SetCacheExpiration(TimeSpan.FromSeconds(10));
+ .SetRefreshInterval(TimeSpan.FromSeconds(10));
});
// Get an instance of the refresher that can be used to refresh data
diff --git a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj
index f11142cc..5fd49e8a 100644
--- a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj
+++ b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj
@@ -2,7 +2,7 @@
- net6.0;net7.0;net8.0
+ net6.0;net8.0
Microsoft.Azure.AppConfiguration.AspNetCore allows developers to use Microsoft Azure App Configuration service as a configuration source in their applications. This package adds additional features for ASP.NET Core applications to the existing package Microsoft.Extensions.Configuration.AzureAppConfiguration.
true
false
diff --git a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj
index 6ae5ccb4..edb4bf28 100644
--- a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj
+++ b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj
@@ -2,7 +2,7 @@
- net6.0;net7.0;net8.0
+ net6.0;net8.0
Microsoft.Azure.AppConfiguration.Functions.Worker allows developers to use the Microsoft Azure App Configuration service as a configuration source in their applications. This package adds additional features to the existing package Microsoft.Extensions.Configuration.AzureAppConfiguration for .NET Azure Functions running in an isolated process.
true
false
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs
index d79838da..7d9a9cad 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs
@@ -35,10 +35,15 @@ public class AzureAppConfigurationOptions
private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase)));
///
- /// Flag to indicate whether enable replica discovery.
+ /// 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.
///
@@ -119,7 +124,7 @@ internal IEnumerable Adapters
///
/// Indicates all types of feature filters used by the application.
///
- internal FeatureFilterTracing FeatureFilterTracing { get; set; } = new FeatureFilterTracing();
+ internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing();
///
/// Options used to configure provider startup.
@@ -135,7 +140,7 @@ public AzureAppConfigurationOptions()
{
new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()),
new JsonKeyValueAdapter(),
- new FeatureManagementKeyValueAdapter(FeatureFilterTracing)
+ new FeatureManagementKeyValueAdapter(FeatureFlagTracing)
};
}
@@ -215,10 +220,10 @@ public AzureAppConfigurationOptions UseFeatureFlags(Action c
FeatureFlagOptions options = new FeatureFlagOptions();
configure?.Invoke(options);
- if (options.CacheExpirationInterval < RefreshConstants.MinimumFeatureFlagsCacheExpirationInterval)
+ if (options.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval)
{
- throw new ArgumentOutOfRangeException(nameof(options.CacheExpirationInterval), options.CacheExpirationInterval.TotalMilliseconds,
- string.Format(ErrorMessages.CacheExpirationTimeTooShort, RefreshConstants.MinimumFeatureFlagsCacheExpirationInterval.TotalMilliseconds));
+ throw new ArgumentOutOfRangeException(nameof(options.RefreshInterval), options.RefreshInterval.TotalMilliseconds,
+ string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds));
}
if (options.FeatureFlagSelectors.Count() != 0 && options.Label != null)
@@ -247,8 +252,8 @@ public AzureAppConfigurationOptions UseFeatureFlags(Action c
{
Key = featureFlagFilter,
Label = labelFilter,
- // If UseFeatureFlags is called multiple times for the same key and label filters, last cache expiration time wins
- CacheExpirationInterval = options.CacheExpirationInterval
+ // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins
+ RefreshInterval = options.RefreshInterval
});
}
@@ -381,7 +386,7 @@ public AzureAppConfigurationOptions ConfigureRefresh(Action _mappedData;
private Dictionary _watchedSettings = new Dictionary();
private RequestTracingOptions _requestTracingOptions;
private Dictionary _configClientBackoffs = new Dictionary();
- private readonly TimeSpan MinCacheExpirationInterval;
+ private readonly TimeSpan MinRefreshInterval;
// The most-recent time when the refresh operation attempted to load the initial configuration
private DateTimeOffset InitializationCacheExpires = default;
@@ -111,11 +112,11 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan
if (watchers.Any())
{
- MinCacheExpirationInterval = watchers.Min(w => w.CacheExpirationInterval);
+ MinRefreshInterval = watchers.Min(w => w.RefreshInterval);
}
else
{
- MinCacheExpirationInterval = RefreshConstants.DefaultCacheExpirationInterval;
+ MinRefreshInterval = RefreshConstants.DefaultRefreshInterval;
}
// Enable request tracing if not opt-out
@@ -193,13 +194,13 @@ public async Task RefreshAsync(CancellationToken cancellationToken)
EnsureAssemblyInspected();
var utcNow = DateTimeOffset.UtcNow;
- IEnumerable cacheExpiredWatchers = _options.ChangeWatchers.Where(changeWatcher => utcNow >= changeWatcher.CacheExpires);
- IEnumerable cacheExpiredMultiKeyWatchers = _options.MultiKeyWatchers.Where(changeWatcher => utcNow >= changeWatcher.CacheExpires);
+ IEnumerable refreshableWatchers = _options.ChangeWatchers.Where(changeWatcher => utcNow >= changeWatcher.NextRefreshTime);
+ IEnumerable refreshableMultiKeyWatchers = _options.MultiKeyWatchers.Where(changeWatcher => utcNow >= changeWatcher.NextRefreshTime);
- // Skip refresh if mappedData is loaded, but none of the watchers or adapters cache is expired.
+ // Skip refresh if mappedData is loaded, but none of the watchers or adapters are refreshable.
if (_mappedData != null &&
- !cacheExpiredWatchers.Any() &&
- !cacheExpiredMultiKeyWatchers.Any() &&
+ !refreshableWatchers.Any() &&
+ !refreshableMultiKeyWatchers.Any() &&
!_options.Adapters.Any(adapter => adapter.NeedsRefresh()))
{
return;
@@ -238,7 +239,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken)
{
if (InitializationCacheExpires < utcNow)
{
- InitializationCacheExpires = utcNow.Add(MinCacheExpirationInterval);
+ InitializationCacheExpires = utcNow.Add(MinRefreshInterval);
await InitializeAsync(clients, cancellationToken).ConfigureAwait(false);
}
@@ -267,7 +268,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) =>
logDebugBuilder.Clear();
logInfoBuilder.Clear();
- foreach (KeyValueWatcher changeWatcher in cacheExpiredWatchers)
+ foreach (KeyValueWatcher changeWatcher in refreshableWatchers)
{
string watchedKey = changeWatcher.Key;
string watchedLabel = changeWatcher.Label;
@@ -338,7 +339,7 @@ await CallWithRequestTracing(
return;
}
- changedKeyValuesCollection = await GetRefreshedKeyValueCollections(cacheExpiredMultiKeyWatchers, client, logDebugBuilder, logInfoBuilder, endpoint, cancellationToken).ConfigureAwait(false);
+ changedKeyValuesCollection = await GetRefreshedKeyValueCollections(refreshableMultiKeyWatchers, client, logDebugBuilder, logInfoBuilder, endpoint, cancellationToken).ConfigureAwait(false);
if (!changedKeyValuesCollection.Any())
{
@@ -352,9 +353,9 @@ await CallWithRequestTracing(
{
watchedSettings = new Dictionary(_watchedSettings);
- foreach (KeyValueWatcher changeWatcher in cacheExpiredWatchers.Concat(cacheExpiredMultiKeyWatchers))
+ foreach (KeyValueWatcher changeWatcher in refreshableWatchers.Concat(refreshableMultiKeyWatchers))
{
- UpdateCacheExpirationTime(changeWatcher);
+ UpdateNextRefreshTime(changeWatcher);
}
foreach (KeyValueChange change in keyValueChanges.Concat(changedKeyValuesCollection))
@@ -389,14 +390,14 @@ await CallWithRequestTracing(
// Invalidate the cached Key Vault secret (if any) for this ConfigurationSetting
foreach (IKeyValueAdapter adapter in _options.Adapters)
{
- // If the current setting is null, try to invalidate the previous setting instead
+ // If the current setting is null, try to pass the previous setting instead
if (change.Current != null)
{
- adapter.InvalidateCache(change.Current);
+ adapter.OnChangeDetected(change.Current);
}
else if (change.Previous != null)
{
- adapter.InvalidateCache(change.Previous);
+ adapter.OnChangeDetected(change.Previous);
}
}
}
@@ -408,13 +409,13 @@ await CallWithRequestTracing(
// Invalidate all the cached KeyVault secrets
foreach (IKeyValueAdapter adapter in _options.Adapters)
{
- adapter.InvalidateCache();
+ adapter.OnChangeDetected();
}
- // Update the cache expiration time for all refresh registered settings and feature flags
+ // Update the next refresh time for all refresh registered settings and feature flags
foreach (KeyValueWatcher changeWatcher in _options.ChangeWatchers.Concat(_options.MultiKeyWatchers))
{
- UpdateCacheExpirationTime(changeWatcher);
+ UpdateNextRefreshTime(changeWatcher);
}
}
@@ -552,16 +553,16 @@ public void ProcessPushNotification(PushNotification pushNotification, TimeSpan?
private void SetDirty(TimeSpan? maxDelay)
{
- DateTimeOffset cacheExpires = AddRandomDelay(DateTimeOffset.UtcNow, maxDelay ?? DefaultMaxSetDirtyDelay);
+ DateTimeOffset nextRefreshTime = AddRandomDelay(DateTimeOffset.UtcNow, maxDelay ?? DefaultMaxSetDirtyDelay);
foreach (KeyValueWatcher changeWatcher in _options.ChangeWatchers)
{
- changeWatcher.CacheExpires = cacheExpires;
+ changeWatcher.NextRefreshTime = nextRefreshTime;
}
foreach (KeyValueWatcher changeWatcher in _options.MultiKeyWatchers)
{
- changeWatcher.CacheExpires = cacheExpires;
+ changeWatcher.NextRefreshTime = nextRefreshTime;
}
}
@@ -569,8 +570,8 @@ private async Task> PrepareData(Dictionary(StringComparer.OrdinalIgnoreCase);
- // Reset old filter tracing in order to track the filter types present in the current response from server.
- _options.FeatureFilterTracing.ResetFeatureFilterTracing();
+ // Reset old feature flag tracing in order to track the information present in the current response from server.
+ _options.FeatureFlagTracing.ResetFeatureFlagTracing();
foreach (KeyValuePair kvp in data)
{
@@ -728,10 +729,10 @@ await ExecuteWithFailOverPolicyAsync(
cancellationToken)
.ConfigureAwait(false);
- // Update the cache expiration time for all refresh registered settings and feature flags
+ // Update the next refresh time for all refresh registered settings and feature flags
foreach (KeyValueWatcher changeWatcher in _options.ChangeWatchers.Concat(_options.MultiKeyWatchers))
{
- UpdateCacheExpirationTime(changeWatcher);
+ UpdateNextRefreshTime(changeWatcher);
}
if (data != null)
@@ -739,7 +740,7 @@ await ExecuteWithFailOverPolicyAsync(
// Invalidate all the cached KeyVault secrets
foreach (IKeyValueAdapter adapter in _options.Adapters)
{
- adapter.InvalidateCache();
+ adapter.OnChangeDetected();
}
Dictionary mappedData = await MapConfigurationSettings(data).ConfigureAwait(false);
@@ -918,6 +919,11 @@ private void SetData(IDictionary data)
// Set the application data for the configuration provider
Data = data;
+ foreach (IKeyValueAdapter adapter in _options.Adapters)
+ {
+ adapter.OnConfigUpdated();
+ }
+
// Notify that the configuration has been updated
OnReload();
}
@@ -933,7 +939,7 @@ private async Task>> ProcessAdapters(Co
continue;
}
- IEnumerable> kvs = await adapter.ProcessKeyValue(setting, _logger, cancellationToken).ConfigureAwait(false);
+ IEnumerable> kvs = await adapter.ProcessKeyValue(setting, AppConfigurationEndpoint, _logger, cancellationToken).ConfigureAwait(false);
if (kvs != null)
{
@@ -961,7 +967,8 @@ private void SetRequestTracingOptions()
IsKeyVaultConfigured = _options.IsKeyVaultConfigured,
IsKeyVaultRefreshConfigured = _options.IsKeyVaultRefreshConfigured,
ReplicaCount = _options.Endpoints?.Count() - 1 ?? _options.ConnectionStrings?.Count() - 1 ?? 0,
- FilterTracing = _options.FeatureFilterTracing
+ FeatureFlagTracing = _options.FeatureFlagTracing,
+ IsLoadBalancingEnabled = _options.LoadBalancingEnabled
};
}
@@ -986,10 +993,9 @@ private bool IsAuthenticationError(Exception ex)
return false;
}
- private void UpdateCacheExpirationTime(KeyValueWatcher changeWatcher)
+ private void UpdateNextRefreshTime(KeyValueWatcher changeWatcher)
{
- TimeSpan cacheExpirationTime = changeWatcher.CacheExpirationInterval;
- changeWatcher.CacheExpires = DateTimeOffset.UtcNow.Add(cacheExpirationTime);
+ changeWatcher.NextRefreshTime = DateTimeOffset.UtcNow.Add(changeWatcher.RefreshInterval);
}
private async Task ExecuteWithFailOverPolicyAsync(
@@ -997,6 +1003,32 @@ private async Task ExecuteWithFailOverPolicyAsync(
Func> funcToExecute,
CancellationToken cancellationToken = default)
{
+ if (_requestTracingEnabled && _requestTracingOptions != null)
+ {
+ _requestTracingOptions.IsFailoverRequest = false;
+ }
+
+ if (_options.LoadBalancingEnabled && _lastSuccessfulEndpoint != null && clients.Count() > 1)
+ {
+ int nextClientIndex = 0;
+
+ foreach (ConfigurationClient client in clients)
+ {
+ nextClientIndex++;
+
+ if (_configClientManager.GetEndpointForClient(client) == _lastSuccessfulEndpoint)
+ {
+ break;
+ }
+ }
+
+ // If we found the last successful client, we'll rotate the list so that the next client is at the beginning
+ if (nextClientIndex < clients.Count())
+ {
+ clients = clients.Skip(nextClientIndex).Concat(clients.Take(nextClientIndex));
+ }
+ }
+
using IEnumerator clientEnumerator = clients.GetEnumerator();
clientEnumerator.MoveNext();
@@ -1017,6 +1049,8 @@ private async Task ExecuteWithFailOverPolicyAsync(
T result = await funcToExecute(currentClient).ConfigureAwait(false);
success = true;
+ _lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient);
+
return result;
}
catch (RequestFailedException rfe)
@@ -1067,6 +1101,11 @@ private async Task ExecuteWithFailOverPolicyAsync(
}
previousEndpoint = currentEndpoint;
+
+ if (_requestTracingEnabled && _requestTracingOptions != null)
+ {
+ _requestTracingOptions.IsFailoverRequest = true;
+ }
}
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs
index 7522896e..f3fb6c4a 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs
@@ -12,7 +12,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
///
public class AzureAppConfigurationRefreshOptions
{
- internal TimeSpan CacheExpirationInterval { get; private set; } = RefreshConstants.DefaultCacheExpirationInterval;
+ internal TimeSpan RefreshInterval { get; private set; } = RefreshConstants.DefaultRefreshInterval;
internal ISet RefreshRegistrations = new HashSet();
///
@@ -55,15 +55,28 @@ public AzureAppConfigurationRefreshOptions Register(string key, string label = L
/// Any refresh operation triggered using will not update the value for a key until the cached value for that key has expired.
///
/// Minimum time that must elapse before the cache is expired.
+ [Obsolete("The " + nameof(SetCacheExpiration) + " method is deprecated and will be removed in a future release. " +
+ "Please use the " + nameof(SetRefreshInterval) + " method instead. " +
+ "Note that only the name of the method has changed, and the functionality remains the same.")]
public AzureAppConfigurationRefreshOptions SetCacheExpiration(TimeSpan cacheExpiration)
{
- if (cacheExpiration < RefreshConstants.MinimumCacheExpirationInterval)
+ return SetRefreshInterval(cacheExpiration);
+ }
+
+ ///
+ /// Sets the minimum time interval between consecutive refresh operations for the registered key-values. Default value is 30 seconds. Must be greater than 1 second.
+ /// Refresh operations triggered using will not make any server requests unless the refresh interval has elapsed since the key was last refreshed.
+ ///
+ /// Minimum time that must elapse between each refresh for a specific key.
+ public AzureAppConfigurationRefreshOptions SetRefreshInterval(TimeSpan refreshInterval)
+ {
+ if (refreshInterval < RefreshConstants.MinimumRefreshInterval)
{
- throw new ArgumentOutOfRangeException(nameof(cacheExpiration), cacheExpiration.TotalMilliseconds,
- string.Format(ErrorMessages.CacheExpirationTimeTooShort, RefreshConstants.MinimumCacheExpirationInterval.TotalMilliseconds));
+ throw new ArgumentOutOfRangeException(nameof(refreshInterval), refreshInterval.TotalMilliseconds,
+ string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumRefreshInterval.TotalMilliseconds));
}
- CacheExpirationInterval = cacheExpiration;
+ RefreshInterval = refreshInterval;
return this;
}
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs
index 09544029..dee62006 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs
@@ -37,11 +37,20 @@ public IConfigurationProvider Build(IConfigurationBuilder builder)
}
else if (options.ConnectionStrings != null)
{
- clientManager = new ConfigurationClientManager(options.ConnectionStrings, options.ClientOptions, options.ReplicaDiscoveryEnabled);
+ clientManager = new ConfigurationClientManager(
+ options.ConnectionStrings,
+ options.ClientOptions,
+ options.ReplicaDiscoveryEnabled,
+ options.LoadBalancingEnabled);
}
else if (options.Endpoints != null && options.Credential != null)
{
- clientManager = new ConfigurationClientManager(options.Endpoints, options.Credential, options.ClientOptions, options.ReplicaDiscoveryEnabled);
+ clientManager = new ConfigurationClientManager(
+ options.Endpoints,
+ options.Credential,
+ options.ClientOptions,
+ options.ReplicaDiscoveryEnabled,
+ options.LoadBalancingEnabled);
}
else
{
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs
index f0880809..a272b413 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs
@@ -27,7 +27,7 @@ public AzureKeyVaultKeyValueAdapter(AzureKeyVaultSecretProvider secretProvider)
/// Uses the Azure Key Vault secret provider to resolve Key Vault references retrieved from Azure App Configuration.
/// inputs the IKeyValue
/// returns the keyname and actual value
- public async Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken)
+ public async Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken)
{
string secretRefUri = ParseSecretReferenceUri(setting);
@@ -76,7 +76,7 @@ public bool CanProcess(ConfigurationSetting setting)
return string.Equals(contentType, KeyVaultConstants.ContentType);
}
- public void InvalidateCache(ConfigurationSetting setting = null)
+ public void OnChangeDetected(ConfigurationSetting setting = null)
{
if (setting == null)
{
@@ -96,6 +96,11 @@ public void InvalidateCache(ConfigurationSetting setting = null)
}
}
+ public void OnConfigUpdated()
+ {
+ return;
+ }
+
public bool NeedsRefresh()
{
return _secretProvider.ShouldRefreshKeyVaultSecrets();
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs
index 06f916b0..a0215ca3 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs
@@ -54,7 +54,8 @@ internal class ConfigurationClientManager : IConfigurationClientManager, IDispos
public ConfigurationClientManager(
IEnumerable connectionStrings,
ConfigurationClientOptions clientOptions,
- bool replicaDiscoveryEnabled)
+ bool replicaDiscoveryEnabled,
+ bool loadBalancingEnabled)
{
if (connectionStrings == null || !connectionStrings.Any())
{
@@ -68,6 +69,12 @@ public ConfigurationClientManager(
_clientOptions = clientOptions;
_replicaDiscoveryEnabled = replicaDiscoveryEnabled;
+ // If load balancing is enabled, shuffle the passed in connection strings to randomize the endpoint used on startup
+ if (loadBalancingEnabled)
+ {
+ connectionStrings = connectionStrings.ToList().Shuffle();
+ }
+
_validDomain = GetValidDomain(_endpoint);
_srvLookupClient = new SrvLookupClient();
@@ -84,7 +91,8 @@ public ConfigurationClientManager(
IEnumerable endpoints,
TokenCredential credential,
ConfigurationClientOptions clientOptions,
- bool replicaDiscoveryEnabled)
+ bool replicaDiscoveryEnabled,
+ bool loadBalancingEnabled)
{
if (endpoints == null || !endpoints.Any())
{
@@ -101,6 +109,12 @@ public ConfigurationClientManager(
_clientOptions = clientOptions;
_replicaDiscoveryEnabled = replicaDiscoveryEnabled;
+ // If load balancing is enabled, shuffle the passed in endpoints to randomize the endpoint used on startup
+ if (loadBalancingEnabled)
+ {
+ endpoints = endpoints.ToList().Shuffle();
+ }
+
_validDomain = GetValidDomain(_endpoint);
_srvLookupClient = new SrvLookupClient();
@@ -132,6 +146,7 @@ public IEnumerable GetClients()
_ = DiscoverFallbackClients();
}
+ // Treat the passed in endpoints as the highest priority clients
IEnumerable clients = _clients.Select(c => c.Client);
if (_dynamicClients != null && _dynamicClients.Any())
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs
index 61b4bb3b..c7974736 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs
@@ -5,10 +5,10 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
{
internal class ErrorMessages
{
- public const string CacheExpirationTimeTooShort = "The cache expiration time cannot be less than {0} milliseconds.";
+ public const string RefreshIntervalTooShort = "The refresh interval cannot be less than {0} milliseconds.";
public const string SecretRefreshIntervalTooShort = "The secret refresh interval cannot be less than {0} milliseconds.";
public const string FeatureFlagInvalidJsonProperty = "Invalid property '{0}' for feature flag. Key: '{1}'. Found type: '{2}'. Expected type: '{3}'.";
- public const string FeatureFlagInvalidFormat = "Invalid json format for feature flag. Key: '{0}'";
+ public const string FeatureFlagInvalidFormat = "Invalid json format for feature flag. Key: '{0}'.";
public const string InvalidKeyVaultReference = "Invalid Key Vault reference.";
}
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RefreshConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RefreshConstants.cs
index 954e3775..2c1616fe 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RefreshConstants.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RefreshConstants.cs
@@ -8,12 +8,12 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
internal class RefreshConstants
{
// Key-values
- public static readonly TimeSpan DefaultCacheExpirationInterval = TimeSpan.FromSeconds(30);
- public static readonly TimeSpan MinimumCacheExpirationInterval = TimeSpan.FromSeconds(1);
+ public static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(30);
+ public static readonly TimeSpan MinimumRefreshInterval = TimeSpan.FromSeconds(1);
// Feature flags
- public static readonly TimeSpan DefaultFeatureFlagsCacheExpirationInterval = TimeSpan.FromSeconds(30);
- public static readonly TimeSpan MinimumFeatureFlagsCacheExpirationInterval = TimeSpan.FromSeconds(1);
+ public static readonly TimeSpan DefaultFeatureFlagRefreshInterval = TimeSpan.FromSeconds(30);
+ public static readonly TimeSpan MinimumFeatureFlagRefreshInterval = TimeSpan.FromSeconds(1);
// Key Vault secrets
public static readonly TimeSpan MinimumSecretRefreshInterval = TimeSpan.FromMinutes(1);
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs
index 6ff59fa1..15e862b6 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs
@@ -22,7 +22,6 @@ internal class RequestTracingConstants
public const string RequestTypeKey = "RequestType";
public const string HostTypeKey = "Host";
- public const string FilterTypeKey = "Filter";
public const string EnvironmentKey = "Env";
public const string FeatureManagementVersionKey = "FMVer";
public const string FeatureManagementAspNetCoreVersionKey = "FMANCVer";
@@ -30,7 +29,21 @@ internal class RequestTracingConstants
public const string KeyVaultConfiguredTag = "UsesKeyVault";
public const string KeyVaultRefreshConfiguredTag = "RefreshesKeyVault";
public const string ReplicaCountKey = "ReplicaCount";
- public const string SignalRUsedTag = "UsesSignalR";
+ public const string FeaturesKey = "Features";
+ public const string LoadBalancingEnabledTag = "LB";
+ public const string SignalRUsedTag = "SignalR";
+ public const string FailoverRequestTag = "Failover";
+
+ public const string FeatureFlagFilterTypeKey = "Filter";
+ public const string CustomFilter = "CSTM";
+ public const string PercentageFilter = "PRCNT";
+ public const string TimeWindowFilter = "TIME";
+ public const string TargetingFilter = "TRGT";
+ public const string FeatureFlagFeaturesKey = "FFFeatures";
+ public const string FeatureFlagUsesTelemetryTag = "Telemetry";
+ public const string FeatureFlagUsesSeedTag = "Seed";
+ public const string FeatureFlagMaxVariantsKey = "MaxVariants";
+ public const string FeatureFlagUsesVariantConfigurationReferenceTag = "ConfigRef";
public const string DiagnosticHeaderActivityName = "Azure.CustomDiagnosticHeaders";
public const string CorrelationContextHeader = "Correlation-Context";
@@ -38,7 +51,8 @@ internal class RequestTracingConstants
public const string FeatureManagementAssemblyName = "Microsoft.FeatureManagement";
public const string FeatureManagementAspNetCoreAssemblyName = "Microsoft.FeatureManagement.AspNetCore";
-
public const string SignalRAssemblyName = "Microsoft.AspNetCore.SignalR";
+
+ public const string Delimiter = "+";
}
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/BytesExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/BytesExtensions.cs
new file mode 100644
index 00000000..3c5266ec
--- /dev/null
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/BytesExtensions.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+using System;
+using System.Text;
+
+namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions
+{
+ internal static class BytesExtensions
+ {
+ ///
+ /// Converts a byte array to Base64URL string with optional padding ('=') characters removed.
+ /// Base64 description: https://datatracker.ietf.org/doc/html/rfc4648.html#section-4
+ ///
+ public static string ToBase64Url(this byte[] bytes)
+ {
+ string bytesBase64 = Convert.ToBase64String(bytes);
+
+ int indexOfEquals = bytesBase64.IndexOf("=");
+
+ // Skip the optional padding of '=' characters based on the Base64Url spec if any are present from the Base64 conversion
+ int stringBuilderCapacity = indexOfEquals != -1 ? indexOfEquals : bytesBase64.Length;
+
+ StringBuilder stringBuilder = new StringBuilder(stringBuilderCapacity);
+
+ // Construct Base64URL string by replacing characters in Base64 conversion that are not URL safe
+ for (int i = 0; i < stringBuilderCapacity; i++)
+ {
+ if (bytesBase64[i] == '+')
+ {
+ stringBuilder.Append('-');
+ }
+ else if (bytesBase64[i] == '/')
+ {
+ stringBuilder.Append('_');
+ }
+ else
+ {
+ stringBuilder.Append(bytesBase64[i]);
+ }
+ }
+
+ return stringBuilder.ToString();
+ }
+ }
+}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs
index 99024c3c..93722539 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs
@@ -1,15 +1,15 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT license.
-//
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
using DnsClient.Protocol;
using System;
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-
-namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions
-{
- internal static class ListExtensions
- {
+
+namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions
+{
+ internal static class ListExtensions
+ {
public static List Shuffle(this List values)
{
var rdm = new Random();
@@ -63,6 +63,6 @@ public static void AppendUnique(this List items, T item)
// Append to the end, keeping precedence.
items.Add(item);
- }
+ }
}
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureAllocation.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureAllocation.cs
new file mode 100644
index 00000000..0b2877e6
--- /dev/null
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureAllocation.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+using System.Collections.Generic;
+
+namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement
+{
+ internal class FeatureAllocation
+ {
+ public string DefaultWhenDisabled { get; set; }
+
+ public string DefaultWhenEnabled { get; set; }
+
+ public IEnumerable User { get; set; }
+
+ public IEnumerable Group { get; set; }
+
+ public IEnumerable Percentile { get; set; }
+
+ public string Seed { get; set; }
+ }
+}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs
index aaf38540..31af50a6 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
+using System.Collections.Generic;
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement
{
@@ -11,5 +12,11 @@ internal class FeatureFlag
public bool Enabled { get; set; }
public FeatureConditions Conditions { get; set; }
+
+ public IEnumerable Variants { get; set; }
+
+ public FeatureAllocation Allocation { get; set; }
+
+ public FeatureTelemetry Telemetry { get; set; }
}
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs
index c63fddc1..1e8beae6 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs
@@ -14,11 +14,22 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManage
///
public class FeatureFlagOptions
{
+ private TimeSpan _refreshInterval = RefreshConstants.DefaultFeatureFlagRefreshInterval;
+
///
/// A collection of .
///
internal List FeatureFlagSelectors = new List();
+ ///
+ /// The time after which feature flags can be refreshed. Must be greater than or equal to 1 second.
+ ///
+ internal TimeSpan RefreshInterval
+ {
+ get { return _refreshInterval; }
+ set { _refreshInterval = value; }
+ }
+
///
/// The label that feature flags will be selected from.
///
@@ -27,7 +38,27 @@ public class FeatureFlagOptions
///
/// The time after which the cached values of the feature flags expire. Must be greater than or equal to 1 second.
///
- public TimeSpan CacheExpirationInterval { get; set; } = RefreshConstants.DefaultFeatureFlagsCacheExpirationInterval;
+ [Obsolete("The " + nameof(CacheExpirationInterval) + " property is deprecated and will be removed in a future release. " +
+ "Please use the new " + nameof(SetRefreshInterval) + " method instead. " +
+ "Note that the usage has changed, but the functionality remains the same.")]
+ public TimeSpan CacheExpirationInterval
+ {
+ get { return _refreshInterval; }
+ set { _refreshInterval = value; }
+ }
+
+ ///
+ /// Sets the time after which feature flags can be refreshed.
+ ///
+ ///
+ /// Sets the minimum time interval between consecutive refresh operations for feature flags. Default value is 30 seconds. Must be greater than or equal to 1 second.
+ ///
+ public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval)
+ {
+ RefreshInterval = refreshInterval;
+
+ return this;
+ }
///
/// Specify what feature flags to include in the configuration provider.
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFilterTracing.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs
similarity index 55%
rename from src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFilterTracing.cs
rename to src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs
index deb6db49..f48b5220 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFilterTracing.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs
@@ -12,14 +12,8 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManage
///
/// Tracing for tracking built-in feature filter usage.
///
- internal class FeatureFilterTracing
+ internal class FeatureFlagTracing
{
- private const string CustomFilter = "CSTM";
- private const string PercentageFilter = "PRCNT";
- private const string TimeWindowFilter = "TIME";
- private const string TargetingFilter = "TRGT";
- private const string FilterTypeDelimiter = "+";
-
// Built-in Feature Filter Names
private readonly List PercentageFilterNames = new List { "Percentage", "Microsoft.Percentage", "PercentageFilter", "Microsoft.PercentageFilter" };
private readonly List TimeWindowFilterNames = new List { "TimeWindow", "Microsoft.TimeWindow", "TimeWindowFilter", "Microsoft.TimeWindowFilter" };
@@ -29,18 +23,31 @@ internal class FeatureFilterTracing
public bool UsesPercentageFilter { get; set; } = false;
public bool UsesTimeWindowFilter { get; set; } = false;
public bool UsesTargetingFilter { get; set; } = false;
+ public bool UsesSeed { get; set; } = false;
+ public bool UsesTelemetry { get; set; } = false;
+ public bool UsesVariantConfigurationReference { get; set; } = false;
+ public int MaxVariants { get; set; }
public bool UsesAnyFeatureFilter()
{
return UsesCustomFilter || UsesPercentageFilter || UsesTimeWindowFilter || UsesTargetingFilter;
}
- public void ResetFeatureFilterTracing()
+ public bool UsesAnyTracingFeature()
+ {
+ return UsesSeed || UsesTelemetry || UsesVariantConfigurationReference;
+ }
+
+ public void ResetFeatureFlagTracing()
{
UsesCustomFilter = false;
UsesPercentageFilter = false;
UsesTimeWindowFilter = false;
UsesTargetingFilter = false;
+ UsesSeed = false;
+ UsesTelemetry = false;
+ UsesVariantConfigurationReference = false;
+ MaxVariants = 0;
}
public void UpdateFeatureFilterTracing(string filterName)
@@ -63,11 +70,19 @@ public void UpdateFeatureFilterTracing(string filterName)
}
}
+ public void NotifyMaxVariants(int currentFlagTotalVariants)
+ {
+ if (currentFlagTotalVariants > MaxVariants)
+ {
+ MaxVariants = currentFlagTotalVariants;
+ }
+ }
+
///
/// Returns a formatted string containing code names, indicating which feature filters are used by the application.
///
/// Formatted string like: "CSTM+PRCNT+TIME+TRGT", "PRCNT+TRGT", etc. If no filters are used, empty string will be returned.
- public override string ToString()
+ public string CreateFiltersString()
{
if (!UsesAnyFeatureFilter())
{
@@ -78,37 +93,78 @@ public override string ToString()
if (UsesCustomFilter)
{
- sb.Append(CustomFilter);
+ sb.Append(RequestTracingConstants.CustomFilter);
}
if (UsesPercentageFilter)
{
if (sb.Length > 0)
{
- sb.Append(FilterTypeDelimiter);
+ sb.Append(RequestTracingConstants.Delimiter);
}
- sb.Append(PercentageFilter);
+ sb.Append(RequestTracingConstants.PercentageFilter);
}
if (UsesTimeWindowFilter)
{
if (sb.Length > 0)
{
- sb.Append(FilterTypeDelimiter);
+ sb.Append(RequestTracingConstants.Delimiter);
}
- sb.Append(TimeWindowFilter);
+ sb.Append(RequestTracingConstants.TimeWindowFilter);
}
if (UsesTargetingFilter)
{
if (sb.Length > 0)
{
- sb.Append(FilterTypeDelimiter);
+ sb.Append(RequestTracingConstants.Delimiter);
+ }
+
+ sb.Append(RequestTracingConstants.TargetingFilter);
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Returns a formatted string containing code names, indicating which tracing features are used by feature flags.
+ ///
+ /// Formatted string like: "Seed+ConfigRef+Telemetry". If no tracing features are used, empty string will be returned.
+ public string CreateFeaturesString()
+ {
+ if (!UsesAnyTracingFeature())
+ {
+ return string.Empty;
+ }
+
+ var sb = new StringBuilder();
+
+ if (UsesSeed)
+ {
+ sb.Append(RequestTracingConstants.FeatureFlagUsesSeedTag);
+ }
+
+ if (UsesVariantConfigurationReference)
+ {
+ if (sb.Length > 0)
+ {
+ sb.Append(RequestTracingConstants.Delimiter);
+ }
+
+ sb.Append(RequestTracingConstants.FeatureFlagUsesVariantConfigurationReferenceTag);
+ }
+
+ if (UsesTelemetry)
+ {
+ if (sb.Length > 0)
+ {
+ sb.Append(RequestTracingConstants.Delimiter);
}
- sb.Append(TargetingFilter);
+ sb.Append(RequestTracingConstants.FeatureFlagUsesTelemetryTag);
}
return sb.ToString();
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureGroupAllocation.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureGroupAllocation.cs
new file mode 100644
index 00000000..f39ca8cd
--- /dev/null
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureGroupAllocation.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+using System.Collections.Generic;
+
+namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement
+{
+ internal class FeatureGroupAllocation
+ {
+ public string Variant { get; set; }
+
+ public IEnumerable Groups { get; set; }
+ }
+}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs
index e4775950..c6d86d84 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs
@@ -1,22 +1,53 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
+
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement
{
internal class FeatureManagementConstants
{
public const string FeatureFlagMarker = ".appconfig.featureflag/";
public const string ContentType = "application/vnd.microsoft.appconfig.ff+json";
- public const string SectionName = "FeatureManagement";
- public const string EnabledFor = "EnabledFor";
- public const string RequirementType = "RequirementType";
- public const string EnabledJsonPropertyName = "enabled";
- public const string IdJsonPropertyName = "id";
- public const string ConditionsJsonPropertyName = "conditions";
- public const string RequirementTypeJsonPropertyName = "requirement_type";
- public const string ClientFiltersJsonPropertyName = "client_filters";
- public const string NameJsonPropertyName = "name";
- public const string ParametersJsonPropertyName = "parameters";
+ // Feature management section keys
+ public const string FeatureManagementSectionName = "feature_management";
+ public const string FeatureFlagsSectionName = "feature_flags";
+
+ // Feature flag properties
+ public const string Id = "id";
+ public const string Enabled = "enabled";
+ public const string Conditions = "conditions";
+ public const string ClientFilters = "client_filters";
+ public const string Variants = "variants";
+ public const string Allocation = "allocation";
+ public const string UserAllocation = "user";
+ public const string GroupAllocation = "group";
+ public const string PercentileAllocation = "percentile";
+ public const string Telemetry = "telemetry";
+ public const string Metadata = "metadata";
+ public const string RequirementType = "requirement_type";
+ public const string Name = "name";
+ public const string Parameters = "parameters";
+ public const string Variant = "variant";
+ public const string ConfigurationValue = "configuration_value";
+ public const string ConfigurationReference = "configuration_reference";
+ public const string StatusOverride = "status_override";
+ public const string DefaultWhenDisabled = "default_when_disabled";
+ public const string DefaultWhenEnabled = "default_when_enabled";
+ public const string Users = "users";
+ public const string Groups = "groups";
+ public const string From = "from";
+ public const string To = "to";
+ public const string Seed = "seed";
+
+ // Telemetry metadata keys
+ public const string ETag = "ETag";
+ public const string FeatureFlagId = "FeatureFlagId";
+ public const string FeatureFlagReference = "FeatureFlagReference";
+
+ // Dotnet schema keys
+ public const string DotnetSchemaSectionName = "FeatureManagement";
+ public const string DotnetSchemaEnabledFor = "EnabledFor";
+ public const string DotnetSchemaRequirementType = "RequirementType";
}
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs
index 058f353c..2b0e0833 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs
@@ -2,9 +2,12 @@
// Licensed under the MIT license.
//
using Azure.Data.AppConfiguration;
+using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions;
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -13,78 +16,313 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManage
{
internal class FeatureManagementKeyValueAdapter : IKeyValueAdapter
{
- private FeatureFilterTracing _featureFilterTracing;
+ private FeatureFlagTracing _featureFlagTracing;
+ private int _featureFlagIndex = 0;
- public FeatureManagementKeyValueAdapter(FeatureFilterTracing featureFilterTracing)
+ public FeatureManagementKeyValueAdapter(FeatureFlagTracing featureFlagTracing)
{
- _featureFilterTracing = featureFilterTracing ?? throw new ArgumentNullException(nameof(featureFilterTracing));
+ _featureFlagTracing = featureFlagTracing ?? throw new ArgumentNullException(nameof(featureFlagTracing));
}
- public Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken)
+ public Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken)
{
FeatureFlag featureFlag = ParseFeatureFlag(setting.Key, setting.Value);
var keyValues = new List>();
- if (!string.IsNullOrEmpty(featureFlag.Id))
+ // Check if we need to process the feature flag using the microsoft schema
+ if ((featureFlag.Variants != null && featureFlag.Variants.Any()) || featureFlag.Allocation != null || featureFlag.Telemetry != null)
{
- if (featureFlag.Enabled)
+ keyValues = ProcessMicrosoftSchemaFeatureFlag(featureFlag, setting, endpoint);
+ }
+ else
+ {
+ keyValues = ProcessDotnetSchemaFeatureFlag(featureFlag, setting, endpoint);
+ }
+
+ return Task.FromResult>>(keyValues);
+ }
+
+ public bool CanProcess(ConfigurationSetting setting)
+ {
+ string contentType = setting?.ContentType?.Split(';')[0].Trim();
+
+ return string.Equals(contentType, FeatureManagementConstants.ContentType) ||
+ setting.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker);
+ }
+
+ public bool NeedsRefresh()
+ {
+ return false;
+ }
+
+ public void OnChangeDetected(ConfigurationSetting setting = null)
+ {
+ return;
+ }
+
+ public void OnConfigUpdated()
+ {
+ _featureFlagIndex = 0;
+
+ return;
+ }
+
+ private List> ProcessDotnetSchemaFeatureFlag(FeatureFlag featureFlag, ConfigurationSetting setting, Uri endpoint)
+ {
+ var keyValues = new List>();
+
+ if (string.IsNullOrEmpty(featureFlag.Id))
+ {
+ return keyValues;
+ }
+
+ string featureFlagPath = $"{FeatureManagementConstants.DotnetSchemaSectionName}:{featureFlag.Id}";
+
+ if (featureFlag.Enabled)
+ {
+ if (featureFlag.Conditions?.ClientFilters == null || !featureFlag.Conditions.ClientFilters.Any())
+ {
+ keyValues.Add(new KeyValuePair(featureFlagPath, true.ToString()));
+ }
+ else
{
- if (featureFlag.Conditions?.ClientFilters == null || !featureFlag.Conditions.ClientFilters.Any())
+ for (int i = 0; i < featureFlag.Conditions.ClientFilters.Count; i++)
+ {
+ ClientFilter clientFilter = featureFlag.Conditions.ClientFilters[i];
+
+ _featureFlagTracing.UpdateFeatureFilterTracing(clientFilter.Name);
+
+ string clientFiltersPath = $"{featureFlagPath}:{FeatureManagementConstants.DotnetSchemaEnabledFor}:{i}";
+
+ keyValues.Add(new KeyValuePair($"{clientFiltersPath}:Name", clientFilter.Name));
+
+ foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(clientFilter.Parameters))
+ {
+ keyValues.Add(new KeyValuePair($"{clientFiltersPath}:Parameters:{kvp.Key}", kvp.Value));
+ }
+ }
+
+ //
+ // process RequirementType only when filters are not empty
+ if (featureFlag.Conditions.RequirementType != null)
{
- keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}", true.ToString()));
+ keyValues.Add(new KeyValuePair(
+ $"{featureFlagPath}:{FeatureManagementConstants.DotnetSchemaRequirementType}",
+ featureFlag.Conditions.RequirementType));
}
- else
+ }
+ }
+ else
+ {
+ keyValues.Add(new KeyValuePair($"{featureFlagPath}", false.ToString()));
+ }
+
+ return keyValues;
+ }
+
+ private List> ProcessMicrosoftSchemaFeatureFlag(FeatureFlag featureFlag, ConfigurationSetting setting, Uri endpoint)
+ {
+ var keyValues = new List>();
+
+ if (string.IsNullOrEmpty(featureFlag.Id))
+ {
+ return keyValues;
+ }
+
+ string featureFlagPath = $"{FeatureManagementConstants.FeatureManagementSectionName}:{FeatureManagementConstants.FeatureFlagsSectionName}:{_featureFlagIndex}";
+
+ _featureFlagIndex++;
+
+ keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.Id}", featureFlag.Id));
+
+ keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.Enabled}", featureFlag.Enabled.ToString()));
+
+ if (featureFlag.Enabled)
+ {
+ if (featureFlag.Conditions?.ClientFilters != null && featureFlag.Conditions.ClientFilters.Any()) // workaround since we are not yet setting client filters to null
+ {
+ //
+ // Conditionally based on feature filters
+ for (int i = 0; i < featureFlag.Conditions.ClientFilters.Count; i++)
{
- for (int i = 0; i < featureFlag.Conditions.ClientFilters.Count; i++)
+ ClientFilter clientFilter = featureFlag.Conditions.ClientFilters[i];
+
+ _featureFlagTracing.UpdateFeatureFilterTracing(clientFilter.Name);
+
+ string clientFiltersPath = $"{featureFlagPath}:{FeatureManagementConstants.Conditions}:{FeatureManagementConstants.ClientFilters}:{i}";
+
+ keyValues.Add(new KeyValuePair($"{clientFiltersPath}:{FeatureManagementConstants.Name}", clientFilter.Name));
+
+ foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(clientFilter.Parameters))
{
- ClientFilter clientFilter = featureFlag.Conditions.ClientFilters[i];
+ keyValues.Add(new KeyValuePair($"{clientFiltersPath}:{FeatureManagementConstants.Parameters}:{kvp.Key}", kvp.Value));
+ }
+ }
- _featureFilterTracing.UpdateFeatureFilterTracing(clientFilter.Name);
+ //
+ // process RequirementType only when filters are not empty
+ if (featureFlag.Conditions.RequirementType != null)
+ {
+ keyValues.Add(new KeyValuePair(
+ $"{featureFlagPath}:{FeatureManagementConstants.Conditions}:{FeatureManagementConstants.RequirementType}",
+ featureFlag.Conditions.RequirementType));
+ }
+ }
+ }
- keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.EnabledFor}:{i}:Name", clientFilter.Name));
+ if (featureFlag.Variants != null)
+ {
+ int i = 0;
- foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(clientFilter.Parameters))
- {
- keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.EnabledFor}:{i}:Parameters:{kvp.Key}", kvp.Value));
- }
+ foreach (FeatureVariant featureVariant in featureFlag.Variants)
+ {
+ string variantsPath = $"{featureFlagPath}:{FeatureManagementConstants.Variants}:{i}";
+
+ keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.Name}", featureVariant.Name));
+
+ foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(featureVariant.ConfigurationValue))
+ {
+ keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.ConfigurationValue}" +
+ (string.IsNullOrEmpty(kvp.Key) ? "" : $":{kvp.Key}"), kvp.Value));
+ }
+
+ if (featureVariant.ConfigurationReference != null)
+ {
+ _featureFlagTracing.UsesVariantConfigurationReference = true;
+
+ keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.ConfigurationReference}", featureVariant.ConfigurationReference));
+ }
+
+ if (featureVariant.StatusOverride != null)
+ {
+ keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.StatusOverride}", featureVariant.StatusOverride));
+ }
+
+ i++;
+ }
+
+ _featureFlagTracing.NotifyMaxVariants(i);
+ }
+
+ if (featureFlag.Allocation != null)
+ {
+ FeatureAllocation allocation = featureFlag.Allocation;
+
+ string allocationPath = $"{featureFlagPath}:{FeatureManagementConstants.Allocation}";
+
+ if (allocation.DefaultWhenDisabled != null)
+ {
+ keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.DefaultWhenDisabled}", allocation.DefaultWhenDisabled));
+ }
+
+ if (allocation.DefaultWhenEnabled != null)
+ {
+ keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.DefaultWhenEnabled}", allocation.DefaultWhenEnabled));
+ }
+
+ if (allocation.User != null)
+ {
+ int i = 0;
+
+ foreach (FeatureUserAllocation userAllocation in allocation.User)
+ {
+ keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.UserAllocation}:{i}:{FeatureManagementConstants.Variant}", userAllocation.Variant));
+
+ int j = 0;
+
+ foreach (string user in userAllocation.Users)
+ {
+ keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.UserAllocation}:{i}:{FeatureManagementConstants.Users}:{j}", user));
+
+ j++;
}
- //
- // process RequirementType only when filters are not empty
- if (featureFlag.Conditions.RequirementType != null)
+ i++;
+ }
+ }
+
+ if (allocation.Group != null)
+ {
+ int i = 0;
+
+ foreach (FeatureGroupAllocation groupAllocation in allocation.Group)
+ {
+ keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.GroupAllocation}:{i}:{FeatureManagementConstants.Variant}", groupAllocation.Variant));
+
+ int j = 0;
+
+ foreach (string group in groupAllocation.Groups)
{
- keyValues.Add(new KeyValuePair(
- $"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.RequirementType}",
- featureFlag.Conditions.RequirementType));
+ keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.GroupAllocation}:{i}:{FeatureManagementConstants.Groups}:{j}", group));
+
+ j++;
}
+
+ i++;
}
}
- else
+
+ if (allocation.Percentile != null)
+ {
+ int i = 0;
+
+ foreach (FeaturePercentileAllocation percentileAllocation in allocation.Percentile)
+ {
+ keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{i}:{FeatureManagementConstants.Variant}", percentileAllocation.Variant));
+
+ keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{i}:{FeatureManagementConstants.From}", percentileAllocation.From.ToString()));
+
+ keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{i}:{FeatureManagementConstants.To}", percentileAllocation.To.ToString()));
+
+ i++;
+ }
+ }
+
+ if (allocation.Seed != null)
{
- keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}", false.ToString()));
+ _featureFlagTracing.UsesSeed = true;
+
+ keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.Seed}", allocation.Seed));
}
}
- return Task.FromResult>>(keyValues);
- }
+ if (featureFlag.Telemetry != null)
+ {
+ FeatureTelemetry telemetry = featureFlag.Telemetry;
- public bool CanProcess(ConfigurationSetting setting)
- {
- string contentType = setting?.ContentType?.Split(';')[0].Trim();
+ string telemetryPath = $"{featureFlagPath}:{FeatureManagementConstants.Telemetry}";
- return string.Equals(contentType, FeatureManagementConstants.ContentType) ||
- setting.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker);
- }
+ if (telemetry.Enabled)
+ {
+ _featureFlagTracing.UsesTelemetry = true;
- public void InvalidateCache(ConfigurationSetting setting = null)
- {
- return;
- }
+ if (telemetry.Metadata != null)
+ {
+ foreach (KeyValuePair kvp in telemetry.Metadata)
+ {
+ keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{kvp.Key}", kvp.Value));
+ }
+ }
- public bool NeedsRefresh()
- {
- return false;
+ string featureFlagId = CalculateFeatureFlagId(setting.Key, setting.Label);
+
+ keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.FeatureFlagId}", featureFlagId));
+
+ if (endpoint != null)
+ {
+ string featureFlagReference = $"{endpoint.AbsoluteUri}kv/{setting.Key}{(!string.IsNullOrWhiteSpace(setting.Label) ? $"?label={setting.Label}" : "")}";
+
+ keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.FeatureFlagReference}", featureFlagReference));
+ }
+
+ keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.ETag}", setting.ETag.ToString()));
+
+ keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Enabled}", telemetry.Enabled.ToString()));
+ }
+ }
+
+ return keyValues;
}
private FormatException CreateFeatureFlagFormatException(string jsonPropertyName, string settingKey, string foundJsonValueKind, string expectedJsonValueKind)
@@ -99,7 +337,7 @@ private FormatException CreateFeatureFlagFormatException(string jsonPropertyName
private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue)
{
- FeatureFlag featureFlag = new FeatureFlag();
+ var featureFlag = new FeatureFlag();
var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(settingValue));
@@ -121,7 +359,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue)
switch (propertyName)
{
- case FeatureManagementConstants.IdJsonPropertyName:
+ case FeatureManagementConstants.Id:
{
if (reader.Read() && reader.TokenType == JsonTokenType.String)
{
@@ -130,7 +368,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue)
else if (reader.TokenType != JsonTokenType.Null)
{
throw CreateFeatureFlagFormatException(
- FeatureManagementConstants.IdJsonPropertyName,
+ FeatureManagementConstants.Id,
settingKey,
reader.TokenType.ToString(),
JsonTokenType.String.ToString());
@@ -139,7 +377,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue)
break;
}
- case FeatureManagementConstants.EnabledJsonPropertyName:
+ case FeatureManagementConstants.Enabled:
{
if (reader.Read() && (reader.TokenType == JsonTokenType.False || reader.TokenType == JsonTokenType.True))
{
@@ -152,7 +390,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue)
else
{
throw CreateFeatureFlagFormatException(
- FeatureManagementConstants.EnabledJsonPropertyName,
+ FeatureManagementConstants.Enabled,
settingKey,
reader.TokenType.ToString(),
$"{JsonTokenType.True}' or '{JsonTokenType.False}");
@@ -161,7 +399,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue)
break;
}
- case FeatureManagementConstants.ConditionsJsonPropertyName:
+ case FeatureManagementConstants.Conditions:
{
if (reader.Read() && reader.TokenType == JsonTokenType.StartObject)
{
@@ -170,7 +408,88 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue)
else if (reader.TokenType != JsonTokenType.Null)
{
throw CreateFeatureFlagFormatException(
- FeatureManagementConstants.ConditionsJsonPropertyName,
+ FeatureManagementConstants.Conditions,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartObject.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.Allocation:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.StartObject)
+ {
+ featureFlag.Allocation = ParseFeatureAllocation(ref reader, settingKey);
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Allocation,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartObject.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.Variants:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.StartArray)
+ {
+ List variants = new List();
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
+ {
+ int i = 0;
+
+ if (reader.TokenType == JsonTokenType.StartObject)
+ {
+ FeatureVariant featureVariant = ParseFeatureVariant(ref reader, settingKey);
+
+ if (featureVariant.Name != null)
+ {
+ variants.Add(featureVariant);
+ }
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ $"{FeatureManagementConstants.Variants}[{i}]",
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartObject.ToString());
+ }
+
+ i++;
+ }
+
+ featureFlag.Variants = variants;
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Variants,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartArray.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.Telemetry:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.StartObject)
+ {
+ featureFlag.Telemetry = ParseFeatureTelemetry(ref reader, settingKey);
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Telemetry,
settingKey,
reader.TokenType.ToString(),
JsonTokenType.StartObject.ToString());
@@ -188,7 +507,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue)
}
catch (JsonException e)
{
- throw new FormatException(string.Format(ErrorMessages.FeatureFlagInvalidFormat, settingKey), e);
+ throw new FormatException(settingKey, e);
}
return featureFlag;
@@ -209,14 +528,12 @@ private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, stri
switch (conditionsPropertyName)
{
- case FeatureManagementConstants.ClientFiltersJsonPropertyName:
+ case FeatureManagementConstants.ClientFilters:
{
- if (reader.Read() && reader.TokenType == JsonTokenType.Null)
- {
- break;
- }
- else if (reader.TokenType == JsonTokenType.StartArray)
+ if (reader.Read() && reader.TokenType == JsonTokenType.StartArray)
{
+ int i = 0;
+
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType == JsonTokenType.StartObject)
@@ -230,12 +547,22 @@ private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, stri
featureConditions.ClientFilters.Add(clientFilter);
}
}
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ $"{FeatureManagementConstants.ClientFilters}[{i}]",
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartObject.ToString());
+ }
+
+ i++;
}
}
- else
+ else if (reader.TokenType != JsonTokenType.Null)
{
throw CreateFeatureFlagFormatException(
- FeatureManagementConstants.ClientFiltersJsonPropertyName,
+ FeatureManagementConstants.ClientFilters,
settingKey,
reader.TokenType.ToString(),
JsonTokenType.StartArray.ToString());
@@ -244,7 +571,7 @@ private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, stri
break;
}
- case FeatureManagementConstants.RequirementTypeJsonPropertyName:
+ case FeatureManagementConstants.RequirementType:
{
if (reader.Read() && reader.TokenType == JsonTokenType.String)
{
@@ -253,7 +580,7 @@ private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, stri
else if (reader.TokenType != JsonTokenType.Null)
{
throw CreateFeatureFlagFormatException(
- FeatureManagementConstants.RequirementTypeJsonPropertyName,
+ FeatureManagementConstants.RequirementType,
settingKey,
reader.TokenType.ToString(),
JsonTokenType.String.ToString());
@@ -287,7 +614,7 @@ private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string setting
switch (clientFiltersPropertyName)
{
- case FeatureManagementConstants.NameJsonPropertyName:
+ case FeatureManagementConstants.Name:
{
if (reader.Read() && reader.TokenType == JsonTokenType.String)
{
@@ -296,7 +623,7 @@ private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string setting
else if (reader.TokenType != JsonTokenType.Null)
{
throw CreateFeatureFlagFormatException(
- FeatureManagementConstants.NameJsonPropertyName,
+ FeatureManagementConstants.Name,
settingKey,
reader.TokenType.ToString(),
JsonTokenType.String.ToString());
@@ -305,7 +632,7 @@ private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string setting
break;
}
- case FeatureManagementConstants.ParametersJsonPropertyName:
+ case FeatureManagementConstants.Parameters:
{
if (reader.Read() && reader.TokenType == JsonTokenType.StartObject)
{
@@ -314,7 +641,7 @@ private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string setting
else if (reader.TokenType != JsonTokenType.Null)
{
throw CreateFeatureFlagFormatException(
- FeatureManagementConstants.ParametersJsonPropertyName,
+ FeatureManagementConstants.Parameters,
settingKey,
reader.TokenType.ToString(),
JsonTokenType.StartObject.ToString());
@@ -332,5 +659,660 @@ private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string setting
return clientFilter;
}
+
+ private FeatureAllocation ParseFeatureAllocation(ref Utf8JsonReader reader, string settingKey)
+ {
+ var featureAllocation = new FeatureAllocation();
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
+ {
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ continue;
+ }
+
+ string allocationPropertyName = reader.GetString();
+
+ switch (allocationPropertyName)
+ {
+ case FeatureManagementConstants.DefaultWhenDisabled:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.String)
+ {
+ featureAllocation.DefaultWhenDisabled = reader.GetString();
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.DefaultWhenDisabled,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.DefaultWhenEnabled:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.String)
+ {
+ featureAllocation.DefaultWhenEnabled = reader.GetString();
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.DefaultWhenEnabled,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.UserAllocation:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.StartArray)
+ {
+ List userAllocations = new List();
+
+ int i = 0;
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
+ {
+ if (reader.TokenType == JsonTokenType.StartObject)
+ {
+ FeatureUserAllocation featureUserAllocation = ParseFeatureUserAllocation(ref reader, settingKey);
+
+ if (featureUserAllocation.Variant != null ||
+ (featureUserAllocation.Users != null &&
+ featureUserAllocation.Users.Any()))
+ {
+ userAllocations.Add(featureUserAllocation);
+ }
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ $"{FeatureManagementConstants.UserAllocation}[{i}]",
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartObject.ToString());
+ }
+
+ i++;
+ }
+
+ featureAllocation.User = userAllocations;
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.UserAllocation,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartArray.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.GroupAllocation:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.StartArray)
+ {
+ List groupAllocations = new List();
+
+ int i = 0;
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
+ {
+ if (reader.TokenType == JsonTokenType.StartObject)
+ {
+ FeatureGroupAllocation featureGroupAllocation = ParseFeatureGroupAllocation(ref reader, settingKey);
+
+ if (featureGroupAllocation.Variant != null ||
+ (featureGroupAllocation.Groups != null &&
+ featureGroupAllocation.Groups.Any()))
+ {
+ groupAllocations.Add(featureGroupAllocation);
+ }
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ $"{FeatureManagementConstants.GroupAllocation}[{i}]",
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartObject.ToString());
+ }
+
+ i++;
+ }
+
+ featureAllocation.Group = groupAllocations;
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.GroupAllocation,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartArray.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.PercentileAllocation:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.StartArray)
+ {
+ List percentileAllocations = new List();
+
+ int i = 0;
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
+ {
+ if (reader.TokenType == JsonTokenType.StartObject)
+ {
+ FeaturePercentileAllocation featurePercentileAllocation = ParseFeaturePercentileAllocation(ref reader, settingKey);
+
+ percentileAllocations.Add(featurePercentileAllocation);
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ $"{FeatureManagementConstants.PercentileAllocation}[{i}]",
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartObject.ToString());
+ }
+
+ i++;
+ }
+
+ featureAllocation.Percentile = percentileAllocations;
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.PercentileAllocation,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartArray.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.Seed:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.String)
+ {
+ featureAllocation.Seed = reader.GetString();
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Seed,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+
+ break;
+ }
+
+ default:
+ reader.Skip();
+
+ break;
+ }
+ }
+
+ return featureAllocation;
+ }
+
+ private FeatureUserAllocation ParseFeatureUserAllocation(ref Utf8JsonReader reader, string settingKey)
+ {
+ var featureUserAllocation = new FeatureUserAllocation();
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
+ {
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ continue;
+ }
+
+ string userAllocationPropertyName = reader.GetString();
+
+ switch (userAllocationPropertyName)
+ {
+ case FeatureManagementConstants.Variant:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.String)
+ {
+ featureUserAllocation.Variant = reader.GetString();
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Variant,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.Users:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.StartArray)
+ {
+ List users = new List();
+
+ int i = 0;
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ users.Add(reader.GetString());
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ $"{FeatureManagementConstants.Users}[{i}]",
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+
+ i++;
+ }
+
+ featureUserAllocation.Users = users;
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Users,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartArray.ToString());
+ }
+
+ break;
+ }
+
+ default:
+ reader.Skip();
+
+ break;
+ }
+ }
+
+ return featureUserAllocation;
+ }
+
+ private FeatureGroupAllocation ParseFeatureGroupAllocation(ref Utf8JsonReader reader, string settingKey)
+ {
+ var featureGroupAllocation = new FeatureGroupAllocation();
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
+ {
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ continue;
+ }
+
+ string groupAllocationPropertyName = reader.GetString();
+
+ switch (groupAllocationPropertyName)
+ {
+ case FeatureManagementConstants.Variant:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.String)
+ {
+ featureGroupAllocation.Variant = reader.GetString();
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Variant,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.Groups:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.StartArray)
+ {
+ List groups = new List();
+
+ int i = 0;
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ groups.Add(reader.GetString());
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ $"{FeatureManagementConstants.Groups}[{i}]",
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+
+ i++;
+ }
+
+ featureGroupAllocation.Groups = groups;
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Groups,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartArray.ToString());
+ }
+
+ break;
+ }
+
+ default:
+ reader.Skip();
+
+ break;
+ }
+ }
+
+ return featureGroupAllocation;
+ }
+
+ private FeaturePercentileAllocation ParseFeaturePercentileAllocation(ref Utf8JsonReader reader, string settingKey)
+ {
+ var featurePercentileAllocation = new FeaturePercentileAllocation();
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
+ {
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ continue;
+ }
+
+ string percentileAllocationPropertyName = reader.GetString();
+
+ switch (percentileAllocationPropertyName)
+ {
+ case FeatureManagementConstants.Variant:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.String)
+ {
+ featurePercentileAllocation.Variant = reader.GetString();
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Variant,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.From:
+ {
+ if (reader.Read() &&
+ ((reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out int from)) ||
+ (reader.TokenType == JsonTokenType.String && int.TryParse(reader.GetString(), out from))))
+ {
+ featurePercentileAllocation.From = from;
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.From,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.Number.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.To:
+ {
+ if (reader.Read() &&
+ ((reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out int to)) ||
+ (reader.TokenType == JsonTokenType.String && int.TryParse(reader.GetString(), out to))))
+ {
+ featurePercentileAllocation.To = to;
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.To,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.Number.ToString());
+ }
+
+ break;
+ }
+
+ default:
+ reader.Skip();
+
+ break;
+ }
+ }
+
+ return featurePercentileAllocation;
+ }
+
+ private FeatureVariant ParseFeatureVariant(ref Utf8JsonReader reader, string settingKey)
+ {
+ var featureVariant = new FeatureVariant();
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
+ {
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ continue;
+ }
+
+ string variantPropertyName = reader.GetString();
+
+ switch (variantPropertyName)
+ {
+ case FeatureManagementConstants.Name:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.String)
+ {
+ featureVariant.Name = reader.GetString();
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Name,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.ConfigurationReference:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.String)
+ {
+ featureVariant.ConfigurationReference = reader.GetString();
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.ConfigurationReference,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.ConfigurationValue:
+ {
+ if (reader.Read())
+ {
+ featureVariant.ConfigurationValue = JsonDocument.ParseValue(ref reader).RootElement;
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.StatusOverride:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.String)
+ {
+ featureVariant.StatusOverride = reader.GetString();
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.StatusOverride,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+
+ break;
+ }
+
+ default:
+ reader.Skip();
+
+ break;
+ }
+ }
+
+ return featureVariant;
+ }
+
+ private FeatureTelemetry ParseFeatureTelemetry(ref Utf8JsonReader reader, string settingKey)
+ {
+ var featureTelemetry = new FeatureTelemetry();
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
+ {
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ continue;
+ }
+
+ string telemetryPropertyName = reader.GetString();
+
+ switch (telemetryPropertyName)
+ {
+ case FeatureManagementConstants.Enabled:
+ {
+ if (reader.Read() && (reader.TokenType == JsonTokenType.False || reader.TokenType == JsonTokenType.True))
+ {
+ featureTelemetry.Enabled = reader.GetBoolean();
+ }
+ else if (reader.TokenType == JsonTokenType.String && bool.TryParse(reader.GetString(), out bool enabled))
+ {
+ featureTelemetry.Enabled = enabled;
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Enabled,
+ settingKey,
+ reader.TokenType.ToString(),
+ $"{JsonTokenType.True}' or '{JsonTokenType.False}");
+ }
+
+ break;
+ }
+
+ case FeatureManagementConstants.Metadata:
+ {
+ if (reader.Read() && reader.TokenType == JsonTokenType.StartObject)
+ {
+ featureTelemetry.Metadata = new Dictionary();
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
+ {
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ continue;
+ }
+
+ string metadataPropertyName = reader.GetString();
+
+ if (reader.Read() && reader.TokenType == JsonTokenType.String)
+ {
+ featureTelemetry.Metadata[metadataPropertyName] = reader.GetString();
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ metadataPropertyName,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.String.ToString());
+ }
+ }
+ }
+ else if (reader.TokenType != JsonTokenType.Null)
+ {
+ throw CreateFeatureFlagFormatException(
+ FeatureManagementConstants.Metadata,
+ settingKey,
+ reader.TokenType.ToString(),
+ JsonTokenType.StartObject.ToString());
+ }
+
+ break;
+ }
+
+ default:
+ reader.Skip();
+
+ break;
+ }
+ }
+
+ return featureTelemetry;
+ }
+
+ private static string CalculateFeatureFlagId(string key, string label)
+ {
+ byte[] featureFlagIdHash;
+
+ // Convert the value consisting of key, newline character, and label to a byte array using UTF8 encoding to hash it using SHA 256
+ using (HashAlgorithm hashAlgorithm = SHA256.Create())
+ {
+ featureFlagIdHash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes($"{key}\n{(string.IsNullOrWhiteSpace(label) ? null : label)}"));
+ }
+
+ // Convert the hashed byte array to Base64Url
+ return featureFlagIdHash.ToBase64Url();
+ }
}
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeaturePercentileAllocation.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeaturePercentileAllocation.cs
new file mode 100644
index 00000000..1d107dba
--- /dev/null
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeaturePercentileAllocation.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+
+namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement
+{
+ internal class FeaturePercentileAllocation
+ {
+ public string Variant { get; set; }
+
+ public double From { get; set; }
+
+ public double To { get; set; }
+ }
+}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureTelemetry.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureTelemetry.cs
new file mode 100644
index 00000000..09d8dd71
--- /dev/null
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureTelemetry.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+
+using System.Collections.Generic;
+
+namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement
+{
+ internal class FeatureTelemetry
+ {
+ public bool Enabled { get; set; }
+
+ public IDictionary Metadata { get; set; }
+ }
+}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureUserAllocation.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureUserAllocation.cs
new file mode 100644
index 00000000..fc403d6e
--- /dev/null
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureUserAllocation.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+using System.Collections.Generic;
+
+namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement
+{
+ internal class FeatureUserAllocation
+ {
+ public string Variant { get; set; }
+
+ public IEnumerable Users { get; set; }
+ }
+}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureVariant.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureVariant.cs
new file mode 100644
index 00000000..87c5c0b1
--- /dev/null
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureVariant.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+using System.Text.Json;
+
+namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement
+{
+ internal class FeatureVariant
+ {
+ public string Name { get; set; }
+
+ public JsonElement ConfigurationValue { get; set; }
+
+ public string ConfigurationReference { get; set; }
+
+ public string StatusOverride { get; set; }
+ }
+}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs
index b47184a2..78df7125 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs
@@ -2,7 +2,6 @@
// Licensed under the MIT license.
//
using Azure;
-using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs
index 48fc85c5..de13314e 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT license.
//
using Azure.Data.AppConfiguration;
+using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -10,11 +11,13 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
{
internal interface IKeyValueAdapter
{
- Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken);
+ Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken);
bool CanProcess(ConfigurationSetting setting);
- void InvalidateCache(ConfigurationSetting setting = null);
+ void OnChangeDetected(ConfigurationSetting setting = null);
+
+ void OnConfigUpdated();
bool NeedsRefresh();
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs
index 979811d7..b4448e32 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs
@@ -22,7 +22,7 @@ internal class JsonKeyValueAdapter : IKeyValueAdapter
KeyVaultConstants.ContentType
};
- public Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken)
+ public Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken)
{
if (setting == null)
{
@@ -95,7 +95,12 @@ public bool CanProcess(ConfigurationSetting setting)
return false;
}
- public void InvalidateCache(ConfigurationSetting setting = null)
+ public void OnChangeDetected(ConfigurationSetting setting = null)
+ {
+ return;
+ }
+
+ public void OnConfigUpdated()
{
return;
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs
index e7429a9e..11442dcb 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs
@@ -88,8 +88,7 @@ public static string BuildLastEndpointFailedMessage(string endpoint)
public static string BuildFallbackClientLookupFailMessage(string exceptionMessage)
{
return $"{LoggingConstants.FallbackClientLookupError}\n{exceptionMessage}";
- }
-
+ }
public static string BuildRefreshFailedDueToFormattingErrorMessage(string exceptionMessage)
{
return $"{LoggingConstants.RefreshFailedDueToFormattingError}\n{exceptionMessage}";
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs
index 121a35be..616f8bcd 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs
@@ -26,12 +26,12 @@ internal class KeyValueWatcher
///
/// The minimum time that must elapse before the key-value is refreshed.
///
- public TimeSpan CacheExpirationInterval { get; set; }
+ public TimeSpan RefreshInterval { get; set; }
///
- /// The cache expiration time for the key-value.
+ /// The next time when this key-value can be refreshed.
///
- public DateTimeOffset CacheExpires { get; set; }
+ public DateTimeOffset NextRefreshTime { get; set; }
public override bool Equals(object obj)
{
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs
index b5769b9d..7b06535b 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT license.
//
using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement;
+using System.Text;
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
{
@@ -33,9 +34,9 @@ internal class RequestTracingOptions
public int ReplicaCount { get; set; } = 0;
///
- /// Type of feature filters used by the application.
+ /// Information about feature flags in the application, like filter and variant usage.
///
- public FeatureFilterTracing FilterTracing { get; set; } = new FeatureFilterTracing();
+ public FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing();
///
/// Version of the Microsoft.FeatureManagement assembly, if present in the application.
@@ -51,5 +52,55 @@ internal class RequestTracingOptions
/// Flag to indicate whether Microsoft.AspNetCore.SignalR assembly is present in the application.
///
public bool IsSignalRUsed { get; set; } = false;
+
+ ///
+ /// Flag to indicate whether load balancing is enabled.
+ ///
+ public bool IsLoadBalancingEnabled { get; set; } = false;
+
+ ///
+ /// Flag to indicate whether the request is triggered by a failover.
+ ///
+ public bool IsFailoverRequest { get; set; } = false;
+
+ ///
+ /// Checks whether any tracing feature is used.
+ ///
+ /// True if any tracing feature is used, otherwise false.
+ public bool UsesAnyTracingFeature()
+ {
+ return IsLoadBalancingEnabled || IsSignalRUsed;
+ }
+
+ ///
+ /// Returns a formatted string containing code names, indicating which tracing features are used by the application.
+ ///
+ /// Formatted string like: "LB+SignalR". If no tracing features are used, empty string will be returned.
+ public string CreateFeaturesString()
+ {
+ if (!UsesAnyTracingFeature())
+ {
+ return string.Empty;
+ }
+
+ var sb = new StringBuilder();
+
+ if (IsLoadBalancingEnabled)
+ {
+ sb.Append(RequestTracingConstants.LoadBalancingEnabledTag);
+ }
+
+ if (IsSignalRUsed)
+ {
+ if (sb.Length > 0)
+ {
+ sb.Append(RequestTracingConstants.Delimiter);
+ }
+
+ sb.Append(RequestTracingConstants.SignalRUsedTag);
+ }
+
+ return sb.ToString();
+ }
}
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs
index 2c8d9869..b1b2b196 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs
@@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
+using System.Reflection;
using System.Security;
using System.Text;
using System.Threading.Tasks;
@@ -79,8 +80,28 @@ public static string GetAssemblyVersion(string assemblyName)
{
if (!string.IsNullOrEmpty(assemblyName))
{
- // Return the version using only the first 3 fields and remove additional characters
- return AppDomain.CurrentDomain.GetAssemblies().SingleOrDefault(assembly => assembly.GetName().Name == assemblyName)?.GetName().Version?.ToString(3).Trim('{', '}');
+ Assembly infoVersionAttribute = AppDomain.CurrentDomain.GetAssemblies().SingleOrDefault(assembly => assembly.GetName().Name == assemblyName);
+
+ if (infoVersionAttribute != null)
+ {
+ string informationalVersion = infoVersionAttribute.GetCustomAttribute()?.InformationalVersion;
+
+ if (string.IsNullOrEmpty(informationalVersion))
+ {
+ return null;
+ }
+
+ // Commit information is appended to the informational version starting with a '+', so we remove
+ // the commit information to get just the full name of the version.
+ int plusIndex = informationalVersion.IndexOf('+');
+
+ if (plusIndex != -1)
+ {
+ informationalVersion = informationalVersion.Substring(0, plusIndex);
+ }
+
+ return informationalVersion;
+ }
}
return null;
@@ -135,9 +156,19 @@ private static string CreateCorrelationContextHeader(RequestType requestType, Re
correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.EnvironmentKey, RequestTracingConstants.DevEnvironmentValue));
}
- if (requestTracingOptions.FilterTracing.UsesAnyFeatureFilter())
+ if (requestTracingOptions.FeatureFlagTracing.UsesAnyFeatureFilter())
+ {
+ correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeatureFlagFilterTypeKey, requestTracingOptions.FeatureFlagTracing.CreateFiltersString()));
+ }
+
+ if (requestTracingOptions.FeatureFlagTracing.MaxVariants > 0)
+ {
+ correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeatureFlagMaxVariantsKey, requestTracingOptions.FeatureFlagTracing.MaxVariants.ToString()));
+ }
+
+ if (requestTracingOptions.FeatureFlagTracing.UsesAnyTracingFeature())
{
- correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FilterTypeKey, requestTracingOptions.FilterTracing.ToString()));
+ correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeatureFlagFeaturesKey, requestTracingOptions.FeatureFlagTracing.CreateFeaturesString()));
}
if (requestTracingOptions.FeatureManagementVersion != null)
@@ -150,6 +181,11 @@ private static string CreateCorrelationContextHeader(RequestType requestType, Re
correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeatureManagementAspNetCoreVersionKey, requestTracingOptions.FeatureManagementAspNetCoreVersion));
}
+ if (requestTracingOptions.UsesAnyTracingFeature())
+ {
+ correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeaturesKey, requestTracingOptions.CreateFeaturesString()));
+ }
+
if (requestTracingOptions.IsKeyVaultConfigured)
{
correlationContextTags.Add(RequestTracingConstants.KeyVaultConfiguredTag);
@@ -160,9 +196,9 @@ private static string CreateCorrelationContextHeader(RequestType requestType, Re
correlationContextTags.Add(RequestTracingConstants.KeyVaultRefreshConfiguredTag);
}
- if (requestTracingOptions.IsSignalRUsed)
+ if (requestTracingOptions.IsFailoverRequest)
{
- correlationContextTags.Add(RequestTracingConstants.SignalRUsedTag);
+ correlationContextTags.Add(RequestTracingConstants.FailoverRequestTag);
}
var sb = new StringBuilder();
diff --git a/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj b/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj
index f15c5eb7..bdd1236b 100644
--- a/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj
+++ b/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj
@@ -1,7 +1,7 @@
- net6.0;net7.0;net8.0
+ net6.0;net8.0
8.0
false
true
diff --git a/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj b/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj
index 02f3f7e2..6f9f3b96 100644
--- a/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj
+++ b/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj
@@ -1,7 +1,7 @@
- net6.0;net7.0;net8.0
+ net6.0;net8.0
8.0
false
true
diff --git a/tests/Tests.AzureAppConfiguration/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/FailoverTests.cs
index 03c7951d..86ea96b9 100644
--- a/tests/Tests.AzureAppConfiguration/FailoverTests.cs
+++ b/tests/Tests.AzureAppConfiguration/FailoverTests.cs
@@ -68,7 +68,7 @@ public async Task FailOverTests_ReturnsAllClientsIfAllBackedOff()
options.ConfigureRefresh(refreshOptions =>
{
refreshOptions.Register("TestKey1", "label")
- .SetCacheExpiration(TimeSpan.FromSeconds(1));
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
});
options.ReplicaDiscoveryEnabled = false;
@@ -133,7 +133,7 @@ public void FailOverTests_PropagatesNonFailOverableExceptions()
options.ConfigureRefresh(refreshOptions =>
{
refreshOptions.Register("TestKey1", "label")
- .SetCacheExpiration(TimeSpan.FromSeconds(1));
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
});
refresher = options.GetRefresher();
@@ -193,7 +193,7 @@ public async Task FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest()
options.ConfigureRefresh(refreshOptions =>
{
refreshOptions.Register("TestKey1", "label")
- .SetCacheExpiration(TimeSpan.FromSeconds(1));
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
});
refresher = options.GetRefresher();
@@ -258,7 +258,7 @@ public void FailOverTests_AutoFailover()
options.ConfigureRefresh(refreshOptions =>
{
refreshOptions.Register("TestKey1", "label")
- .SetCacheExpiration(TimeSpan.FromSeconds(1));
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
});
refresher = options.GetRefresher();
})
@@ -272,7 +272,8 @@ public void FailOverTests_ValidateEndpoints()
new[] { new Uri("https://foobar.azconfig.io") },
new DefaultAzureCredential(),
new ConfigurationClientOptions(),
- true);
+ true,
+ false);
Assert.True(configClientManager.IsValidEndpoint("azure.azconfig.io"));
Assert.True(configClientManager.IsValidEndpoint("appconfig.azconfig.io"));
@@ -287,7 +288,8 @@ public void FailOverTests_ValidateEndpoints()
new[] { new Uri("https://foobar.appconfig.azure.com") },
new DefaultAzureCredential(),
new ConfigurationClientOptions(),
- true);
+ true,
+ false);
Assert.True(configClientManager2.IsValidEndpoint("azure.appconfig.azure.com"));
Assert.True(configClientManager2.IsValidEndpoint("azure.z1.appconfig.azure.com"));
@@ -302,7 +304,8 @@ public void FailOverTests_ValidateEndpoints()
new[] { new Uri("https://foobar.azconfig-test.io") },
new DefaultAzureCredential(),
new ConfigurationClientOptions(),
- true);
+ true,
+ false);
Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig-test.io"));
Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig.io"));
@@ -311,7 +314,8 @@ public void FailOverTests_ValidateEndpoints()
new[] { new Uri("https://foobar.z1.appconfig-test.azure.com") },
new DefaultAzureCredential(),
new ConfigurationClientOptions(),
- true);
+ true,
+ false);
Assert.False(configClientManager4.IsValidEndpoint("foobar.z2.appconfig-test.azure.com"));
Assert.False(configClientManager4.IsValidEndpoint("foobar.appconfig-test.azure.com"));
@@ -325,7 +329,8 @@ public void FailOverTests_GetNoDynamicClient()
new[] { new Uri("https://azure.azconfig.io") },
new DefaultAzureCredential(),
new ConfigurationClientOptions(),
- true);
+ true,
+ false);
var clients = configClientManager.GetClients();
diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs
index 2b6a70a2..44c20b4d 100644
--- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs
+++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs
@@ -6,6 +6,7 @@
using Azure.Core.Testing;
using Azure.Data.AppConfiguration;
using Azure.Data.AppConfiguration.Tests;
+using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.AzureAppConfiguration;
using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement;
@@ -14,6 +15,9 @@
using System.Collections.Generic;
using System.Diagnostics.Tracing;
using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
@@ -416,7 +420,209 @@ public class FeatureManagementTests
eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"),
contentType: "text");
- TimeSpan CacheExpirationTime = TimeSpan.FromSeconds(1);
+ List _variantFeatureFlagCollection = new List
+ {
+ ConfigurationModelFactory.ConfigurationSetting(
+ key: FeatureManagementConstants.FeatureFlagMarker + "VariantsFeature1",
+ value: @"
+ {
+ ""id"": ""VariantsFeature1"",
+ ""enabled"": true,
+ ""variants"": [
+ {
+ ""name"": ""Big"",
+ ""configuration_value"": ""600px""
+ },
+ {
+ ""name"": ""Small"",
+ ""configuration_reference"": ""ShoppingCart:Small"",
+ ""status_override"": ""Disabled""
+ }
+ ],
+ ""allocation"": {
+ ""seed"": ""13992821"",
+ ""default_when_disabled"": ""Small"",
+ ""default_when_enabled"": ""Small"",
+ ""user"": [
+ {
+ ""variant"": ""Big"",
+ ""users"": [
+ ""Marsha"",
+ ""John""
+ ]
+ },
+ {
+ ""variant"": ""Small"",
+ ""users"": [
+ ""Alice"",
+ ""Bob""
+ ]
+ }
+ ],
+ ""group"": [
+ {
+ ""variant"": ""Big"",
+ ""groups"": [
+ ""Ring1""
+ ]
+ },
+ {
+ ""variant"": ""Small"",
+ ""groups"": [
+ ""Ring2"",
+ ""Ring3""
+ ]
+ }
+ ],
+ ""percentile"": [
+ {
+ ""variant"": ""Big"",
+ ""from"": 0,
+ ""to"": 50
+ },
+ {
+ ""variant"": ""Small"",
+ ""from"": 50,
+ ""to"": 100
+ }
+ ]
+ }
+ }
+ ",
+ label: default,
+ contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
+ eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")),
+
+ ConfigurationModelFactory.ConfigurationSetting(
+ key: FeatureManagementConstants.FeatureFlagMarker + "VariantsFeature2",
+ value: @"
+ {
+ ""id"": ""VariantsFeature2"",
+ ""enabled"": false,
+ ""variants"": [
+ {
+ ""name"": ""ObjectVariant"",
+ ""configuration_value"": {
+ ""Key1"": ""Value1"",
+ ""Key2"": {
+ ""InsideKey2"": ""Value2""
+ }
+ }
+ },
+ {
+ ""name"": ""NumberVariant"",
+ ""configuration_value"": 100
+ },
+ {
+ ""name"": ""NullVariant"",
+ ""configuration_value"": null
+ },
+ {
+ ""name"": ""MissingValueVariant""
+ },
+ {
+ ""name"": ""BooleanVariant"",
+ ""configuration_value"": true
+ }
+ ],
+ ""allocation"": {
+ ""default_when_disabled"": ""ObjectVariant"",
+ ""default_when_enabled"": ""ObjectVariant""
+ }
+ }
+ ",
+ label: default,
+ contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
+ eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")),
+
+ ConfigurationModelFactory.ConfigurationSetting(
+ key: FeatureManagementConstants.FeatureFlagMarker + "VariantsFeature3",
+ value: @"
+ {
+ ""id"": ""VariantsFeature3"",
+ ""enabled"": ""true"",
+ ""variants"": [
+ {
+ ""name"": ""NumberVariant"",
+ ""configuration_value"": 1
+ },
+ {
+ ""name"": ""NumberVariant"",
+ ""configuration_value"": 2
+ },
+ {
+ ""name"": ""OtherVariant"",
+ ""configuration_value"": ""Other""
+ }
+ ],
+ ""allocation"": {
+ ""default_when_enabled"": ""OtherVariant"",
+ ""default_when_enabled"": ""NumberVariant""
+ }
+ }
+ ",
+ label: default,
+ contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
+ eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")),
+
+ ConfigurationModelFactory.ConfigurationSetting(
+ key: FeatureManagementConstants.FeatureFlagMarker + "VariantsFeature4",
+ value: @"
+ {
+ ""id"": ""VariantsFeature4"",
+ ""enabled"": true,
+ ""variants"": null,
+ ""allocation"": null
+ }
+ ",
+ label: default,
+ contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
+ eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"))
+ };
+
+ List _telemetryFeatureFlagCollection = new List
+ {
+ ConfigurationModelFactory.ConfigurationSetting(
+ key: FeatureManagementConstants.FeatureFlagMarker + "TelemetryFeature1",
+ value: @"
+ {
+ ""id"": ""TelemetryFeature1"",
+ ""enabled"": true,
+ ""telemetry"": {
+ ""enabled"": ""true"",
+ ""metadata"": {
+ ""Tags.Tag1"": ""Tag1Value"",
+ ""Tags.Tag2"": ""Tag2Value""
+ }
+ }
+ }
+ ",
+ label: "label",
+ contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
+ eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")),
+
+ ConfigurationModelFactory.ConfigurationSetting(
+ key: FeatureManagementConstants.FeatureFlagMarker + "TelemetryFeature2",
+ value: @"
+ {
+ ""id"": ""TelemetryFeature2"",
+ ""enabled"": true,
+ ""telemetry"": {
+ ""enabled"": false,
+ ""enabled"": true,
+ ""metadata"": {
+ ""Tags.Tag1"": ""Tag1Value"",
+ ""Tags.Tag1"": ""Tag2Value""
+ }
+ }
+ }
+ ",
+ label: "label",
+ contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
+ eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"))
+ };
+
+ TimeSpan RefreshInterval = TimeSpan.FromSeconds(1);
[Fact]
public void UsesFeatureFlags()
@@ -463,12 +669,82 @@ public async Task WatchesFeatureFlags()
.Returns(new MockAsyncPageable(featureFlags));
IConfigurationRefresher refresher = null;
- var cacheExpirationTimeSpan = TimeSpan.FromSeconds(1);
var config = new ConfigurationBuilder()
.AddAzureAppConfiguration(options =>
{
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
- options.UseFeatureFlags(o => o.CacheExpirationInterval = cacheExpirationTimeSpan);
+ options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval));
+
+ refresher = options.GetRefresher();
+ })
+ .Build();
+
+ Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]);
+ Assert.Equal("Firefox", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:0"]);
+ Assert.Equal("Safari", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:1"]);
+ Assert.Equal("RollOut", config["FeatureManagement:Beta:EnabledFor:1:Name"]);
+ Assert.Equal("20", config["FeatureManagement:Beta:EnabledFor:1:Parameters:Percentage"]);
+ Assert.Equal("US", config["FeatureManagement:Beta:EnabledFor:1:Parameters:Region"]);
+ Assert.Equal("SuperUsers", config["FeatureManagement:Beta:EnabledFor:2:Name"]);
+ Assert.Equal("TimeWindow", config["FeatureManagement:Beta:EnabledFor:3:Name"]);
+ Assert.Equal("/Date(1578643200000)/", config["FeatureManagement:Beta:EnabledFor:3:Parameters:Start"]);
+ Assert.Equal("/Date(1578686400000)/", config["FeatureManagement:Beta:EnabledFor:3:Parameters:End"]);
+
+ featureFlags[0] = ConfigurationModelFactory.ConfigurationSetting(
+ key: FeatureManagementConstants.FeatureFlagMarker + "myFeature",
+ value: @"
+ {
+ ""id"": ""Beta"",
+ ""description"": ""The new beta version of our web site."",
+ ""display_name"": ""Beta Feature"",
+ ""enabled"": true,
+ ""conditions"": {
+ ""client_filters"": [
+ {
+ ""name"": ""Browser"",
+ ""parameters"": {
+ ""AllowedBrowsers"": [ ""Chrome"", ""Edge"" ]
+ }
+ }
+ ]
+ }
+ }
+ ",
+ label: default,
+ contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
+ eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f"));
+
+ featureFlags.Add(_kv2);
+
+ // Sleep to let the refresh interval elapse
+ Thread.Sleep(RefreshInterval);
+ await refresher.RefreshAsync();
+
+ Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]);
+ Assert.Equal("Chrome", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:0"]);
+ Assert.Equal("Edge", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:1"]);
+ Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]);
+ }
+
+ [Fact]
+ public async Task WatchesFeatureFlagsUsingCacheExpirationInterval()
+ {
+ var featureFlags = new List { _kv };
+
+ var mockResponse = new Mock();
+ var mockClient = new Mock(MockBehavior.Strict);
+
+ mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Returns(new MockAsyncPageable(featureFlags));
+
+ var cacheExpirationInterval = TimeSpan.FromSeconds(1);
+
+ IConfigurationRefresher refresher = null;
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
+ options.UseFeatureFlags(o => o.CacheExpirationInterval = cacheExpirationInterval);
refresher = options.GetRefresher();
})
@@ -512,7 +788,7 @@ public async Task WatchesFeatureFlags()
featureFlags.Add(_kv2);
// Sleep to let the cache expire
- Thread.Sleep(cacheExpirationTimeSpan);
+ Thread.Sleep(cacheExpirationInterval);
await refresher.RefreshAsync();
Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]);
@@ -521,6 +797,73 @@ public async Task WatchesFeatureFlags()
Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]);
}
+ [Fact]
+ public async Task SkipRefreshIfRefreshIntervalHasNotElapsed()
+ {
+ var featureFlags = new List { _kv };
+
+ var mockResponse = new Mock();
+ var mockClient = new Mock(MockBehavior.Strict);
+
+ mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Returns(new MockAsyncPageable(featureFlags));
+
+ IConfigurationRefresher refresher = null;
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
+ options.UseFeatureFlags(o => o.SetRefreshInterval(TimeSpan.FromSeconds(10)));
+
+ refresher = options.GetRefresher();
+ })
+ .Build();
+
+ Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]);
+ Assert.Equal("Firefox", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:0"]);
+ Assert.Equal("Safari", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:1"]);
+ Assert.Equal("RollOut", config["FeatureManagement:Beta:EnabledFor:1:Name"]);
+ Assert.Equal("20", config["FeatureManagement:Beta:EnabledFor:1:Parameters:Percentage"]);
+ Assert.Equal("US", config["FeatureManagement:Beta:EnabledFor:1:Parameters:Region"]);
+ Assert.Equal("SuperUsers", config["FeatureManagement:Beta:EnabledFor:2:Name"]);
+ Assert.Equal("TimeWindow", config["FeatureManagement:Beta:EnabledFor:3:Name"]);
+ Assert.Equal("/Date(1578643200000)/", config["FeatureManagement:Beta:EnabledFor:3:Parameters:Start"]);
+ Assert.Equal("/Date(1578686400000)/", config["FeatureManagement:Beta:EnabledFor:3:Parameters:End"]);
+
+ featureFlags[0] = ConfigurationModelFactory.ConfigurationSetting(
+ key: FeatureManagementConstants.FeatureFlagMarker + "myFeature",
+ value: @"
+ {
+ ""id"": ""Beta"",
+ ""description"": ""The new beta version of our web site."",
+ ""display_name"": ""Beta Feature"",
+ ""enabled"": true,
+ ""conditions"": {
+ ""client_filters"": [
+ {
+ ""name"": ""Browser"",
+ ""parameters"": {
+ ""AllowedBrowsers"": [ ""Chrome"", ""Edge"" ]
+ }
+ }
+ ]
+ }
+ }
+ ",
+ label: default,
+ contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
+ eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f"));
+
+ featureFlags.Add(_kv2);
+
+ await refresher.RefreshAsync();
+
+ Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]);
+ Assert.Equal("Firefox", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:0"]);
+ Assert.Equal("Safari", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:1"]);
+ Assert.Null(config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]);
+ }
+
[Fact]
public async Task SkipRefreshIfCacheNotExpired()
{
@@ -652,19 +995,18 @@ public async Task UsesEtagForFeatureFlagRefresh()
.Returns(new MockAsyncPageable(new List { _kv }));
IConfigurationRefresher refresher = null;
- var cacheExpirationTimeSpan = TimeSpan.FromSeconds(1);
var config = new ConfigurationBuilder()
.AddAzureAppConfiguration(options =>
{
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
- options.UseFeatureFlags(o => o.CacheExpirationInterval = cacheExpirationTimeSpan);
+ options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval));
refresher = options.GetRefresher();
})
.Build();
- // Sleep to let the cache expire
- Thread.Sleep(cacheExpirationTimeSpan);
+ // Sleep to wait for refresh interval to elapse
+ Thread.Sleep(RefreshInterval);
await refresher.TryRefreshAsync();
mockClient.Verify(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Exactly(3));
@@ -677,7 +1019,6 @@ public void SelectFeatureFlags()
var mockClient = new Mock(MockBehavior.Strict);
var featureFlagPrefix = "App1";
var labelFilter = "App1_Label";
- var cacheExpiration = TimeSpan.FromSeconds(1);
mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
.Returns(new MockAsyncPageable(_featureFlagCollection.Where(s => s.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker + featureFlagPrefix) && s.Label == labelFilter).ToList()));
@@ -690,7 +1031,7 @@ public void SelectFeatureFlags()
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient);
options.UseFeatureFlags(ff =>
{
- ff.CacheExpirationInterval = cacheExpiration;
+ ff.SetRefreshInterval(RefreshInterval);
ff.Select(featureFlagPrefix + "*", labelFilter);
});
})
@@ -712,7 +1053,7 @@ public void TestNullAndMissingValuesForConditions()
{
var mockResponse = new Mock();
var mockClient = new Mock(MockBehavior.Strict);
- var cacheExpiration = TimeSpan.FromSeconds(1);
+ var refreshInterval = TimeSpan.FromSeconds(1);
mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
.Returns(new MockAsyncPageable(_nullOrMissingConditionsFeatureFlagCollection));
@@ -726,7 +1067,7 @@ public void TestNullAndMissingValuesForConditions()
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient);
options.UseFeatureFlags(ff =>
{
- ff.CacheExpirationInterval = cacheExpiration;
+ ff.SetRefreshInterval(refreshInterval);
ff.Select(KeyFilter.Any);
});
})
@@ -746,7 +1087,7 @@ public void InvalidFeatureFlagFormatsThrowFormatException()
{
var mockResponse = new Mock();
var mockClient = new Mock(MockBehavior.Strict);
- var cacheExpiration = TimeSpan.FromSeconds(1);
+ var refreshInterval = TimeSpan.FromSeconds(1);
mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
.Returns((Func)GetTestKeys);
@@ -771,7 +1112,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct)
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient);
options.UseFeatureFlags(ff =>
{
- ff.CacheExpirationInterval = cacheExpiration;
+ ff.SetRefreshInterval(refreshInterval);
ff.Select(setting.Key.Substring(FeatureManagementConstants.FeatureFlagMarker.Length));
});
})
@@ -1032,8 +1373,8 @@ public async Task DifferentCacheExpirationsForMultipleFeatureFlagRegistrations()
var prefix2 = "App2";
var label1 = "App1_Label";
var label2 = "App2_Label";
- var cacheExpiration1 = TimeSpan.FromSeconds(1);
- var cacheExpiration2 = TimeSpan.FromSeconds(60);
+ var refreshInterval1 = TimeSpan.FromSeconds(1);
+ var refreshInterval2 = TimeSpan.FromSeconds(60);
IConfigurationRefresher refresher = null;
var featureFlagCollection = new List(_featureFlagCollection);
@@ -1051,12 +1392,12 @@ public async Task DifferentCacheExpirationsForMultipleFeatureFlagRegistrations()
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
options.UseFeatureFlags(ff =>
{
- ff.CacheExpirationInterval = cacheExpiration1;
+ ff.SetRefreshInterval(refreshInterval1);
ff.Select(prefix1 + "*", label1);
});
options.UseFeatureFlags(ff =>
{
- ff.CacheExpirationInterval = cacheExpiration2;
+ ff.SetRefreshInterval(refreshInterval2);
ff.Select(prefix2 + "*", label2);
});
@@ -1108,8 +1449,8 @@ public async Task DifferentCacheExpirationsForMultipleFeatureFlagRegistrations()
contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")));
- // Sleep to let the cache for feature flag with label1 expire
- Thread.Sleep(cacheExpiration1);
+ // Sleep to let the refresh interval for feature flag with label1 elapse
+ Thread.Sleep(refreshInterval1);
await refresher.RefreshAsync();
Assert.Equal("Browser", config["FeatureManagement:App1_Feature1:EnabledFor:0:Name"]);
@@ -1124,12 +1465,12 @@ public async Task DifferentCacheExpirationsForMultipleFeatureFlagRegistrations()
}
[Fact]
- public async Task OverwrittenCacheExpirationForSameFeatureFlagRegistrations()
+ public async Task OverwrittenRefreshIntervalForSameFeatureFlagRegistrations()
{
var mockResponse = new Mock();
var mockClient = new Mock(MockBehavior.Strict);
- var cacheExpiration1 = TimeSpan.FromSeconds(1);
- var cacheExpiration2 = TimeSpan.FromSeconds(60);
+ var refreshInterval1 = TimeSpan.FromSeconds(1);
+ var refreshInterval2 = TimeSpan.FromSeconds(60);
IConfigurationRefresher refresher = null;
var featureFlagCollection = new List(_featureFlagCollection);
@@ -1144,13 +1485,13 @@ public async Task OverwrittenCacheExpirationForSameFeatureFlagRegistrations()
{
ff.Select("*", "App1_Label");
ff.Select("*", "App2_Label");
- ff.CacheExpirationInterval = cacheExpiration1;
+ ff.SetRefreshInterval(refreshInterval1);
});
options.UseFeatureFlags(ff =>
{
ff.Select("*", "App1_Label");
ff.Select("*", "App2_Label");
- ff.CacheExpirationInterval = cacheExpiration2;
+ ff.SetRefreshInterval(refreshInterval2);
});
refresher = options.GetRefresher();
@@ -1186,11 +1527,11 @@ public async Task OverwrittenCacheExpirationForSameFeatureFlagRegistrations()
contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f"));
- Thread.Sleep(cacheExpiration1);
+ Thread.Sleep(refreshInterval1);
await refresher.RefreshAsync();
- // The cache expiration time for feature flags was overwritten by second call to UseFeatureFlags.
- // Sleeping for cacheExpiration1 time should not update feature flags.
+ // The refresh interval time for feature flags was overwritten by second call to UseFeatureFlags.
+ // Sleeping for refreshInterval1 time should not update feature flags.
Assert.Equal("True", config["FeatureManagement:App1_Feature1"]);
Assert.Equal("False", config["FeatureManagement:App1_Feature2"]);
Assert.Equal("False", config["FeatureManagement:App2_Feature1"]);
@@ -1205,7 +1546,6 @@ public async Task SelectAndRefreshSingleFeatureFlag()
var mockClient = new Mock(MockBehavior.Strict);
var prefix1 = "Feature1";
var label1 = "App1_Label";
- var cacheExpiration = TimeSpan.FromSeconds(1);
IConfigurationRefresher refresher = null;
var featureFlagCollection = new List(_featureFlagCollection);
@@ -1222,7 +1562,7 @@ public async Task SelectAndRefreshSingleFeatureFlag()
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
options.UseFeatureFlags(ff =>
{
- ff.CacheExpirationInterval = cacheExpiration;
+ ff.SetRefreshInterval(RefreshInterval);
ff.Select(prefix1, label1);
});
@@ -1255,8 +1595,8 @@ public async Task SelectAndRefreshSingleFeatureFlag()
contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f"));
- // Sleep to let the cache for feature flag with label1 expire
- Thread.Sleep(cacheExpiration);
+ // Sleep to let the refresh interval for feature flag with label1 elapse
+ Thread.Sleep(RefreshInterval);
await refresher.RefreshAsync();
Assert.Equal("Browser", config["FeatureManagement:Feature1:EnabledFor:0:Name"]);
@@ -1297,7 +1637,7 @@ public async Task ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefre
.AddAzureAppConfiguration(options =>
{
options.ClientManager = mockClientManager;
- options.UseFeatureFlags(o => o.CacheExpirationInterval = CacheExpirationTime);
+ options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval));
refresher = options.GetRefresher();
})
.Build();
@@ -1325,14 +1665,14 @@ public async Task ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefre
contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f"));
- Thread.Sleep(CacheExpirationTime);
+ Thread.Sleep(RefreshInterval);
await refresher.TryRefreshAsync();
Assert.Equal("AllUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]);
Assert.Contains(LogHelper.BuildFeatureFlagReadMessage("myFeature1", null, TestHelpers.PrimaryConfigStoreEndpoint.ToString().TrimEnd('/')), verboseInvocation);
Assert.Contains(LogHelper.BuildFeatureFlagUpdatedMessage("myFeature1"), informationalInvocation);
featureFlags.RemoveAt(0);
- Thread.Sleep(CacheExpirationTime);
+ Thread.Sleep(RefreshInterval);
await refresher.TryRefreshAsync();
Assert.Null(config["FeatureManagement:MyFeature:EnabledFor:0:Name"]);
@@ -1373,11 +1713,11 @@ public async Task ValidateFeatureFlagsUnchangedLogged()
.AddAzureAppConfiguration(options =>
{
options.ClientManager = mockClientManager;
- options.UseFeatureFlags(o => o.CacheExpirationInterval = CacheExpirationTime);
+ options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval));
options.ConfigureRefresh(refreshOptions =>
{
refreshOptions.Register("TestKey1", "label")
- .SetCacheExpiration(CacheExpirationTime);
+ .SetRefreshInterval(RefreshInterval);
});
refresher = options.GetRefresher();
})
@@ -1386,7 +1726,7 @@ public async Task ValidateFeatureFlagsUnchangedLogged()
Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]);
FirstKeyValue.Value = "newValue1";
- Thread.Sleep(CacheExpirationTime);
+ Thread.Sleep(RefreshInterval);
await refresher.TryRefreshAsync();
Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]);
Assert.Contains(LogHelper.BuildFeatureFlagsUnchangedMessage(TestHelpers.PrimaryConfigStoreEndpoint.ToString().TrimEnd('/')), verboseInvocation);
@@ -1441,9 +1781,9 @@ public async Task MapTransformFeatureFlagWithRefresh()
options.ConfigureRefresh(refreshOptions =>
{
refreshOptions.Register("TestKey1", "label", true)
- .SetCacheExpiration(CacheExpirationTime);
+ .SetRefreshInterval(RefreshInterval);
});
- options.UseFeatureFlags(o => o.CacheExpirationInterval = CacheExpirationTime);
+ options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval));
options.Map((setting) =>
{
if (setting.ContentType == FeatureManagementConstants.ContentType + ";charset=utf-8")
@@ -1499,13 +1839,139 @@ public async Task MapTransformFeatureFlagWithRefresh()
contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f"));
- Thread.Sleep(CacheExpirationTime);
+ Thread.Sleep(RefreshInterval);
await refresher.TryRefreshAsync();
Assert.Equal("newValue1", config["TestKey1"]);
Assert.Equal("NoUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]);
}
+ [Fact]
+ public void WithVariants()
+ {
+ var mockResponse = new Mock();
+ var mockClient = new Mock(MockBehavior.Strict);
+
+ mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Returns(new MockAsyncPageable(_variantFeatureFlagCollection));
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
+ options.UseFeatureFlags();
+ })
+ .Build();
+
+ Assert.Equal("VariantsFeature1", config["feature_management:feature_flags:0:id"]);
+ Assert.Equal("True", config["feature_management:feature_flags:0:enabled"]);
+ Assert.Equal("Big", config["feature_management:feature_flags:0:variants:0:name"]);
+ Assert.Equal("600px", config["feature_management:feature_flags:0:variants:0:configuration_value"]);
+ Assert.Equal("Small", config["feature_management:feature_flags:0:variants:1:name"]);
+ Assert.Equal("ShoppingCart:Small", config["feature_management:feature_flags:0:variants:1:configuration_reference"]);
+ Assert.Equal("Disabled", config["feature_management:feature_flags:0:variants:1:status_override"]);
+ Assert.Equal("Small", config["feature_management:feature_flags:0:allocation:default_when_disabled"]);
+ Assert.Equal("Small", config["feature_management:feature_flags:0:allocation:default_when_enabled"]);
+ Assert.Equal("Big", config["feature_management:feature_flags:0:allocation:user:0:variant"]);
+ Assert.Equal("Marsha", config["feature_management:feature_flags:0:allocation:user:0:users:0"]);
+ Assert.Equal("John", config["feature_management:feature_flags:0:allocation:user:0:users:1"]);
+ Assert.Equal("Small", config["feature_management:feature_flags:0:allocation:user:1:variant"]);
+ Assert.Equal("Alice", config["feature_management:feature_flags:0:allocation:user:1:users:0"]);
+ Assert.Equal("Bob", config["feature_management:feature_flags:0:allocation:user:1:users:1"]);
+ Assert.Equal("Big", config["feature_management:feature_flags:0:allocation:group:0:variant"]);
+ Assert.Equal("Ring1", config["feature_management:feature_flags:0:allocation:group:0:groups:0"]);
+ Assert.Equal("Small", config["feature_management:feature_flags:0:allocation:group:1:variant"]);
+ Assert.Equal("Ring2", config["feature_management:feature_flags:0:allocation:group:1:groups:0"]);
+ Assert.Equal("Ring3", config["feature_management:feature_flags:0:allocation:group:1:groups:1"]);
+ Assert.Equal("Big", config["feature_management:feature_flags:0:allocation:percentile:0:variant"]);
+ Assert.Equal("0", config["feature_management:feature_flags:0:allocation:percentile:0:from"]);
+ Assert.Equal("50", config["feature_management:feature_flags:0:allocation:percentile:0:to"]);
+ Assert.Equal("Small", config["feature_management:feature_flags:0:allocation:percentile:1:variant"]);
+ Assert.Equal("50", config["feature_management:feature_flags:0:allocation:percentile:1:from"]);
+ Assert.Equal("100", config["feature_management:feature_flags:0:allocation:percentile:1:to"]);
+ Assert.Equal("13992821", config["feature_management:feature_flags:0:allocation:seed"]);
+
+ Assert.Equal("VariantsFeature2", config["feature_management:feature_flags:1:id"]);
+ Assert.Equal("False", config["feature_management:feature_flags:1:enabled"]);
+ Assert.Equal("ObjectVariant", config["feature_management:feature_flags:1:variants:0:name"]);
+ Assert.Equal("Value1", config["feature_management:feature_flags:1:variants:0:configuration_value:Key1"]);
+ Assert.Equal("Value2", config["feature_management:feature_flags:1:variants:0:configuration_value:Key2:InsideKey2"]);
+ Assert.Equal("NumberVariant", config["feature_management:feature_flags:1:variants:1:name"]);
+ Assert.Equal("100", config["feature_management:feature_flags:1:variants:1:configuration_value"]);
+ Assert.Equal("NullVariant", config["feature_management:feature_flags:1:variants:2:name"]);
+ Assert.Equal("", config["feature_management:feature_flags:1:variants:2:configuration_value"]);
+ Assert.True(config
+ .GetSection("feature_management:feature_flags:1:variants:2")
+ .AsEnumerable()
+ .ToDictionary(x => x.Key, x => x.Value)
+ .ContainsKey("feature_management:feature_flags:1:variants:2:configuration_value"));
+ Assert.Equal("MissingValueVariant", config["feature_management:feature_flags:1:variants:3:name"]);
+ Assert.Null(config["feature_management:feature_flags:1:variants:3:configuration_value"]);
+ Assert.False(config
+ .GetSection("feature_management:feature_flags:1:variants:3")
+ .AsEnumerable()
+ .ToDictionary(x => x.Key, x => x.Value)
+ .ContainsKey("feature_management:feature_flags:1:variants:3:configuration_value"));
+ Assert.Equal("BooleanVariant", config["feature_management:feature_flags:1:variants:4:name"]);
+ Assert.Equal("True", config["feature_management:feature_flags:1:variants:4:configuration_value"]);
+ Assert.Equal("ObjectVariant", config["feature_management:feature_flags:1:allocation:default_when_disabled"]);
+ Assert.Equal("ObjectVariant", config["feature_management:feature_flags:1:allocation:default_when_enabled"]);
+
+ Assert.Equal("VariantsFeature3", config["feature_management:feature_flags:2:id"]);
+ Assert.Equal("True", config["feature_management:feature_flags:2:enabled"]);
+ Assert.Equal("NumberVariant", config["feature_management:feature_flags:2:allocation:default_when_enabled"]);
+ Assert.Equal("1", config["feature_management:feature_flags:2:variants:0:configuration_value"]);
+ Assert.Equal("2", config["feature_management:feature_flags:2:variants:1:configuration_value"]);
+ Assert.Equal("Other", config["feature_management:feature_flags:2:variants:2:configuration_value"]);
+ Assert.Equal("NumberVariant", config["feature_management:feature_flags:2:allocation:default_when_enabled"]);
+
+ Assert.Equal("True", config["FeatureManagement:VariantsFeature4"]);
+ }
+
+ [Fact]
+ public void WithTelemetry()
+ {
+ var mockResponse = new Mock();
+ var mockClient = new Mock(MockBehavior.Strict);
+
+ mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Returns(new MockAsyncPageable(_telemetryFeatureFlagCollection));
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
+ options.Connect(TestHelpers.PrimaryConfigStoreEndpoint, new DefaultAzureCredential());
+ options.UseFeatureFlags();
+ })
+ .Build();
+
+ Assert.Equal("True", config["feature_management:feature_flags:0:telemetry:enabled"]);
+ Assert.Equal("TelemetryFeature1", config["feature_management:feature_flags:0:id"]);
+ Assert.Equal("Tag1Value", config["feature_management:feature_flags:0:telemetry:metadata:Tags.Tag1"]);
+ Assert.Equal("Tag2Value", config["feature_management:feature_flags:0:telemetry:metadata:Tags.Tag2"]);
+ Assert.Equal("c3c231fd-39a0-4cb6-3237-4614474b92c1", config["feature_management:feature_flags:0:telemetry:metadata:ETag"]);
+
+ byte[] featureFlagIdHash;
+
+ using (HashAlgorithm hashAlgorithm = SHA256.Create())
+ {
+ featureFlagIdHash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes($"{FeatureManagementConstants.FeatureFlagMarker}TelemetryFeature1\nlabel"));
+ }
+
+ string featureFlagId = Convert.ToBase64String(featureFlagIdHash)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+
+ Assert.Equal(featureFlagId, config["feature_management:feature_flags:0:telemetry:metadata:FeatureFlagId"]);
+ Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}TelemetryFeature1?label=label", config["feature_management:feature_flags:0:telemetry:metadata:FeatureFlagReference"]);
+
+ Assert.Equal("True", config["feature_management:feature_flags:1:telemetry:enabled"]);
+ Assert.Equal("TelemetryFeature2", config["feature_management:feature_flags:1:id"]);
+ Assert.Equal("Tag2Value", config["feature_management:feature_flags:1:telemetry:metadata:Tags.Tag1"]);
+ }
+
[Fact]
public void WithRequirementType()
{
@@ -1524,9 +1990,9 @@ public void WithRequirementType()
var featureFlags = new List()
{
_kv2,
- FeatureWithRequirementType("Feature_NoFilters", "All", emptyFilters),
- FeatureWithRequirementType("Feature_RequireAll", "All", nonEmptyFilters),
- FeatureWithRequirementType("Feature_RequireAny", "Any", nonEmptyFilters)
+ CreateFeatureFlag("Feature_NoFilters", requirementType: "\"All\"", clientFiltersJsonString: emptyFilters),
+ CreateFeatureFlag("Feature_RequireAll", requirementType: "\"All\"", clientFiltersJsonString: nonEmptyFilters),
+ CreateFeatureFlag("Feature_RequireAny", requirementType: "\"Any\"", clientFiltersJsonString: nonEmptyFilters)
};
var mockResponse = new Mock();
@@ -1543,10 +2009,60 @@ public void WithRequirementType()
})
.Build();
- Assert.Null(config["FeatureManagement:MyFeature2:RequirementType"]);
- Assert.Null(config["FeatureManagement:Feature_NoFilters:RequirementType"]);
- Assert.Equal("All", config["FeatureManagement:Feature_RequireAll:RequirementType"]);
- Assert.Equal("Any", config["FeatureManagement:Feature_RequireAny:RequirementType"]);
+ Assert.Null(config["feature_management:feature_flags:0:requirement_type"]);
+ Assert.Equal("Feature_NoFilters", config["feature_management:feature_flags:0:id"]);
+ Assert.Equal("All", config["feature_management:feature_flags:1:conditions:requirement_type"]);
+ Assert.Equal("Feature_RequireAll", config["feature_management:feature_flags:1:id"]);
+ Assert.Equal("Any", config["feature_management:feature_flags:2:conditions:requirement_type"]);
+ Assert.Equal("Feature_RequireAny", config["feature_management:feature_flags:2:id"]);
+ }
+
+ [Fact]
+ public void ThrowsOnIncorrectJsonTypes()
+ {
+ var settings = new List()
+ {
+ CreateFeatureFlag("Feature1", variantsJsonString: @"[{""name"": 1}]"),
+ CreateFeatureFlag("Feature2", variantsJsonString: @"[{""configuration_reference"": true}]"),
+ CreateFeatureFlag("Feature3", variantsJsonString: @"[{""status_override"": []}]"),
+ CreateFeatureFlag("Feature4", seed: "{}"),
+ CreateFeatureFlag("Feature5", defaultWhenDisabled: "5"),
+ CreateFeatureFlag("Feature6", defaultWhenEnabled: "6"),
+ CreateFeatureFlag("Feature7", userJsonString: @"[{""variant"": []}]"),
+ CreateFeatureFlag("Feature8", userJsonString: @"[{""users"": [ {""name"": ""8""} ]}]"),
+ CreateFeatureFlag("Feature9", groupJsonString: @"[{""variant"": false}]"),
+ CreateFeatureFlag("Feature10", groupJsonString: @"[{""groups"": 10}]"),
+ CreateFeatureFlag("Feature11", percentileJsonString: @"[{""variant"": []}]"),
+ CreateFeatureFlag("Feature12", percentileJsonString: @"[{""from"": true}]"),
+ CreateFeatureFlag("Feature13", percentileJsonString: @"[{""to"": {}}]"),
+ CreateFeatureFlag("Feature14", telemetryEnabled: "14"),
+ CreateFeatureFlag("Feature15", telemetryMetadataJsonString: @"{""key"": 15}"),
+ CreateFeatureFlag("Feature16", clientFiltersJsonString: @"[{""name"": 16}]"),
+ CreateFeatureFlag("Feature17", clientFiltersJsonString: @"{""key"": [{""name"": ""name"", ""parameters"": 17}]}"),
+ CreateFeatureFlag("Feature18", requirementType: "18")
+ };
+
+ var mockResponse = new Mock();
+ var mockClient = new Mock(MockBehavior.Strict);
+
+ foreach (ConfigurationSetting setting in settings)
+ {
+ var featureFlags = new List { setting };
+
+ mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Returns(new MockAsyncPageable(featureFlags));
+
+ void action() => new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
+ options.UseFeatureFlags();
+ }).Build();
+
+ var exception = Assert.Throws(action);
+
+ Assert.False(exception.InnerException is JsonException);
+ }
}
Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken)
@@ -1559,18 +2075,42 @@ Response GetTestKey(string key, string label, Cancellation
return Response.FromValue(TestHelpers.CloneSetting(FirstKeyValue), new Mock().Object);
}
- private ConfigurationSetting FeatureWithRequirementType(string featureId, string requirementType, string clientFiltersJsonString)
+ private ConfigurationSetting CreateFeatureFlag(string featureId,
+ string requirementType = "null",
+ string clientFiltersJsonString = "null",
+ string variantsJsonString = "null",
+ string seed = "null",
+ string defaultWhenDisabled = "null",
+ string defaultWhenEnabled = "null",
+ string userJsonString = "null",
+ string groupJsonString = "null",
+ string percentileJsonString = "null",
+ string telemetryEnabled = "null",
+ string telemetryMetadataJsonString = "null")
{
return ConfigurationModelFactory.ConfigurationSetting(
key: FeatureManagementConstants.FeatureFlagMarker + featureId,
value: $@"
{{
- ""id"": ""{featureId}"",
- ""enabled"": true,
- ""conditions"": {{
- ""requirement_type"": ""{requirementType}"",
- ""client_filters"": {clientFiltersJsonString}
- }}
+ ""id"": ""{featureId}"",
+ ""enabled"": true,
+ ""conditions"": {{
+ ""requirement_type"": {requirementType},
+ ""client_filters"": {clientFiltersJsonString}
+ }},
+ ""variants"": {variantsJsonString},
+ ""allocation"": {{
+ ""seed"": {seed},
+ ""default_when_disabled"": {defaultWhenDisabled},
+ ""default_when_enabled"": {defaultWhenEnabled},
+ ""user"": {userJsonString},
+ ""group"": {groupJsonString},
+ ""percentile"": {percentileJsonString}
+ }},
+ ""telemetry"": {{
+ ""enabled"": {telemetryEnabled},
+ ""metadata"": {telemetryMetadataJsonString}
+ }}
}}
",
contentType: FeatureManagementConstants.ContentType + ";charset=utf-8",
diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs
index 9517b709..06c88040 100644
--- a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs
+++ b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs
@@ -507,9 +507,10 @@ public void DoesNotThrowKeyVaultExceptionWhenProviderIsOptional()
var mockKeyValueAdapter = new Mock(MockBehavior.Strict);
mockKeyValueAdapter.Setup(adapter => adapter.CanProcess(_kv))
.Returns(true);
- mockKeyValueAdapter.Setup(adapter => adapter.ProcessKeyValue(_kv, It.IsAny(), It.IsAny()))
+ mockKeyValueAdapter.Setup(adapter => adapter.ProcessKeyValue(_kv, It.IsAny(), It.IsAny(), It.IsAny()))
.Throws(new KeyVaultReferenceException("Key vault error", null));
- mockKeyValueAdapter.Setup(adapter => adapter.InvalidateCache(null));
+ mockKeyValueAdapter.Setup(adapter => adapter.OnChangeDetected(null));
+ mockKeyValueAdapter.Setup(adapter => adapter.OnConfigUpdated());
new ConfigurationBuilder()
.AddAzureAppConfiguration(options =>
@@ -685,7 +686,7 @@ public void ThrowsWhenSecretRefreshIntervalIsTooShort()
public async Task SecretIsReturnedFromCacheIfSecretCacheHasNotExpired()
{
IConfigurationRefresher refresher = null;
- TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(1);
+ TimeSpan refreshInterval = TimeSpan.FromSeconds(1);
var mockResponse = new Mock();
var mockClient = new Mock(MockBehavior.Strict);
@@ -731,7 +732,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o
options.ConfigureRefresh(refreshOptions =>
{
refreshOptions.Register("Sentinel")
- .SetCacheExpiration(cacheExpirationTime);
+ .SetRefreshInterval(refreshInterval);
});
refresher = options.GetRefresher();
@@ -743,7 +744,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o
// Update sentinel key-value
sentinelKv.Value = "Value2";
- Thread.Sleep(cacheExpirationTime);
+ Thread.Sleep(refreshInterval);
await refresher.RefreshAsync();
Assert.Equal("Value2", config["Sentinel"]);
@@ -758,7 +759,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o
public async Task CachedSecretIsInvalidatedWhenRefreshAllIsTrue()
{
IConfigurationRefresher refresher = null;
- TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(60);
+ TimeSpan refreshInterval = TimeSpan.FromSeconds(60);
var mockResponse = new Mock();
var mockClient = new Mock(MockBehavior.Strict);
@@ -803,7 +804,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o
options.ConfigureRefresh(refreshOptions =>
{
refreshOptions.Register("Sentinel", refreshAll: true)
- .SetCacheExpiration(cacheExpirationTime);
+ .SetRefreshInterval(refreshInterval);
});
refresher = options.GetRefresher();
@@ -815,7 +816,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o
// Update sentinel key-value to trigger refresh operation
sentinelKv.Value = "Value2";
- Thread.Sleep(cacheExpirationTime);
+ Thread.Sleep(refreshInterval);
await refresher.RefreshAsync();
Assert.Equal("Value2", config["Sentinel"]);
@@ -830,7 +831,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o
public async Task SecretIsReloadedFromKeyVaultWhenCacheExpires()
{
IConfigurationRefresher refresher = null;
- TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(60);
+ TimeSpan refreshInterval = TimeSpan.FromSeconds(60);
var mockResponse = new Mock();
var mockClient = new Mock(MockBehavior.Strict);
@@ -850,7 +851,7 @@ public async Task SecretIsReloadedFromKeyVaultWhenCacheExpires()
options.ConfigureKeyVault(kv =>
{
kv.Register(mockSecretClient.Object);
- kv.SetSecretRefreshInterval(_kv.Key, cacheExpirationTime);
+ kv.SetSecretRefreshInterval(_kv.Key, refreshInterval);
});
refresher = options.GetRefresher();
@@ -860,7 +861,7 @@ public async Task SecretIsReloadedFromKeyVaultWhenCacheExpires()
Assert.Equal(_secretValue, config[_kv.Key]);
// Sleep to let the secret cache expire
- Thread.Sleep(cacheExpirationTime);
+ Thread.Sleep(refreshInterval);
await refresher.RefreshAsync();
Assert.Equal(_secretValue, config[_kv.Key]);
@@ -873,7 +874,7 @@ public async Task SecretIsReloadedFromKeyVaultWhenCacheExpires()
public async Task SecretsWithDefaultRefreshInterval()
{
IConfigurationRefresher refresher = null;
- TimeSpan shortCacheExpirationTime = TimeSpan.FromSeconds(60);
+ TimeSpan shortRefreshInterval = TimeSpan.FromSeconds(60);
var mockResponse = new Mock();
var mockClient = new Mock(MockBehavior.Strict);
@@ -893,7 +894,7 @@ public async Task SecretsWithDefaultRefreshInterval()
options.ConfigureKeyVault(kv =>
{
kv.Register(mockSecretClient.Object);
- kv.SetSecretRefreshInterval(shortCacheExpirationTime);
+ kv.SetSecretRefreshInterval(shortRefreshInterval);
});
refresher = options.GetRefresher();
@@ -904,7 +905,7 @@ public async Task SecretsWithDefaultRefreshInterval()
Assert.Equal(_secretValue, config["TK2"]);
// Sleep to let the secret cache expire for both secrets
- Thread.Sleep(shortCacheExpirationTime);
+ Thread.Sleep(shortRefreshInterval);
await refresher.RefreshAsync();
Assert.Equal(_secretValue, config["TK1"]);
@@ -918,8 +919,8 @@ public async Task SecretsWithDefaultRefreshInterval()
public async Task SecretsWithDifferentRefreshIntervals()
{
IConfigurationRefresher refresher = null;
- TimeSpan shortCacheExpirationTime = TimeSpan.FromSeconds(60);
- TimeSpan longCacheExpirationTime = TimeSpan.FromDays(1);
+ TimeSpan shortRefreshInterval = TimeSpan.FromSeconds(60);
+ TimeSpan longRefreshInterval = TimeSpan.FromDays(1);
var mockResponse = new Mock();
var mockClient = new Mock(MockBehavior.Strict);
@@ -939,8 +940,8 @@ public async Task SecretsWithDifferentRefreshIntervals()
options.ConfigureKeyVault(kv =>
{
kv.Register(mockSecretClient.Object);
- kv.SetSecretRefreshInterval("TK1", shortCacheExpirationTime);
- kv.SetSecretRefreshInterval(longCacheExpirationTime);
+ kv.SetSecretRefreshInterval("TK1", shortRefreshInterval);
+ kv.SetSecretRefreshInterval(longRefreshInterval);
});
refresher = options.GetRefresher();
@@ -951,7 +952,7 @@ public async Task SecretsWithDifferentRefreshIntervals()
Assert.Equal(_secretValue, config["TK2"]);
// Sleep to let the secret cache expire for one secret
- Thread.Sleep(shortCacheExpirationTime);
+ Thread.Sleep(shortRefreshInterval);
await refresher.RefreshAsync();
Assert.Equal(_secretValue, config["TK1"]);
diff --git a/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs b/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs
new file mode 100644
index 00000000..4429c7be
--- /dev/null
+++ b/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs
@@ -0,0 +1,153 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+using Azure;
+using Azure.Core.Testing;
+using Azure.Data.AppConfiguration;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Configuration.AzureAppConfiguration;
+using Moq;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Tests.AzureAppConfiguration
+{
+ public class LoadBalancingTests
+ {
+ readonly ConfigurationSetting kv = ConfigurationModelFactory.ConfigurationSetting(key: "TestKey1", label: "label", value: "TestValue1",
+ eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"),
+ contentType: "text");
+
+ TimeSpan RefreshInterval = TimeSpan.FromSeconds(1);
+
+ [Fact]
+ public async Task LoadBalancingTests_UsesAllEndpoints()
+ {
+ IConfigurationRefresher refresher = null;
+ var mockResponse = new MockResponse(200);
+
+ var mockClient1 = new Mock(MockBehavior.Strict);
+ mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Returns(new MockAsyncPageable(Enumerable.Empty().ToList()));
+ mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Response.FromValue(kv, mockResponse));
+ mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Response.FromValue(kv, mockResponse));
+ mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true);
+
+ var mockClient2 = new Mock(MockBehavior.Strict);
+ mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Returns(new MockAsyncPageable(Enumerable.Empty().ToList()));
+ mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Response.FromValue(kv, mockResponse));
+ mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Response.FromValue(kv, mockResponse));
+ mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true);
+
+ ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object);
+ ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object);
+
+ var clientList = new List() { cw1, cw2 };
+ var configClientManager = new ConfigurationClientManager(clientList);
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.ClientManager = configClientManager;
+ options.ConfigureRefresh(refreshOptions =>
+ {
+ refreshOptions.Register("TestKey1", "label")
+ .SetRefreshInterval(RefreshInterval);
+ });
+ options.ReplicaDiscoveryEnabled = false;
+ options.LoadBalancingEnabled = true;
+
+ refresher = options.GetRefresher();
+ }).Build();
+
+ // Ensure client 1 was used for startup
+ mockClient1.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
+
+ Thread.Sleep(RefreshInterval);
+ await refresher.RefreshAsync();
+
+ // Ensure client 2 was used for refresh
+ mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(0));
+
+ mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1));
+
+ Thread.Sleep(RefreshInterval);
+ await refresher.RefreshAsync();
+
+ // Ensure client 1 was now used for refresh
+ mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1));
+ }
+
+ [Fact]
+ public async Task LoadBalancingTests_UsesClientAfterBackoffEnds()
+ {
+ IConfigurationRefresher refresher = null;
+ var mockResponse = new MockResponse(200);
+
+ var mockClient1 = new Mock(MockBehavior.Strict);
+ mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Throws(new RequestFailedException(503, "Request failed."));
+ mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Response.FromValue(kv, mockResponse));
+ mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Response.FromValue(kv, mockResponse));
+ mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true);
+
+ var mockClient2 = new Mock(MockBehavior.Strict);
+ mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Returns(new MockAsyncPageable(Enumerable.Empty().ToList()));
+ mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Response.FromValue(kv, mockResponse));
+ mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Response.FromValue(kv, mockResponse));
+ mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true);
+
+ ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object);
+ ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object);
+
+ var clientList = new List() { cw1, cw2 };
+ var configClientManager = new ConfigurationClientManager(clientList);
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.MinBackoffDuration = TimeSpan.FromSeconds(2);
+ options.ClientManager = configClientManager;
+ options.ConfigureRefresh(refreshOptions =>
+ {
+ refreshOptions.Register("TestKey1", "label")
+ .SetRefreshInterval(RefreshInterval);
+ });
+ options.ReplicaDiscoveryEnabled = false;
+ options.LoadBalancingEnabled = true;
+
+ refresher = options.GetRefresher();
+ }).Build();
+
+ // Ensure client 2 was used for startup
+ mockClient2.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
+
+ Thread.Sleep(TimeSpan.FromSeconds(2));
+ await refresher.RefreshAsync();
+
+ // Ensure client 1 has recovered and is used for refresh
+ mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(0));
+
+ mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1));
+
+ Thread.Sleep(RefreshInterval);
+ await refresher.RefreshAsync();
+
+ mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1));
+ }
+ }
+}
diff --git a/tests/Tests.AzureAppConfiguration/LoggingTests.cs b/tests/Tests.AzureAppConfiguration/LoggingTests.cs
index e4392e89..547c65bd 100644
--- a/tests/Tests.AzureAppConfiguration/LoggingTests.cs
+++ b/tests/Tests.AzureAppConfiguration/LoggingTests.cs
@@ -51,7 +51,7 @@ public class LoggingTests
eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"),
contentType: KeyVaultConstants.ContentType + "; charset=utf-8");
- TimeSpan CacheExpirationTime = TimeSpan.FromSeconds(1);
+ TimeSpan RefreshInterval = TimeSpan.FromSeconds(1);
[Fact]
public async Task ValidateExceptionLoggedDuringRefresh()
@@ -80,7 +80,7 @@ public async Task ValidateExceptionLoggedDuringRefresh()
options.ConfigureRefresh(refreshOptions =>
{
refreshOptions.Register("TestKey1", "label")
- .SetCacheExpiration(CacheExpirationTime);
+ .SetRefreshInterval(RefreshInterval);
});
refresher = options.GetRefresher();
@@ -90,7 +90,7 @@ public async Task ValidateExceptionLoggedDuringRefresh()
Assert.Equal("TestValue1", config["TestKey1"]);
FirstKeyValue.Value = "newValue1";
- Thread.Sleep(CacheExpirationTime);
+ Thread.Sleep(RefreshInterval);
await refresher.TryRefreshAsync();
Assert.NotEqual("newValue1", config["TestKey1"]);
@@ -122,7 +122,7 @@ public async Task ValidateUnauthorizedExceptionLoggedDuringRefresh()
options.ConfigureRefresh(refreshOptions =>
{
refreshOptions.Register("TestKey1", "label")
- .SetCacheExpiration(CacheExpirationTime);
+ .SetRefreshInterval(RefreshInterval);
});
refresher = options.GetRefresher();
@@ -132,7 +132,7 @@ public async Task ValidateUnauthorizedExceptionLoggedDuringRefresh()
Assert.Equal("TestValue1", config["TestKey1"]);
FirstKeyValue.Value = "newValue1";
- Thread.Sleep(CacheExpirationTime);
+ Thread.Sleep(RefreshInterval);
await refresher.TryRefreshAsync();
Assert.NotEqual("newValue1", config["TestKey1"]);
@@ -164,7 +164,7 @@ public async Task ValidateInvalidOperationExceptionLoggedDuringRefresh()
options.ConfigureRefresh(refreshOptions =>
{
refreshOptions.Register("TestKey1", "label")
- .SetCacheExpiration(CacheExpirationTime);
+ .SetRefreshInterval(RefreshInterval);
});
refresher = options.GetRefresher();
@@ -174,7 +174,7 @@ public async Task ValidateInvalidOperationExceptionLoggedDuringRefresh()
Assert.Equal("TestValue1", config["TestKey1"]);
FirstKeyValue.Value = "newValue1";
- Thread.Sleep(CacheExpirationTime);
+ Thread.Sleep(RefreshInterval);
await refresher.TryRefreshAsync();
Assert.NotEqual("newValue1", config["TestKey1"]);
@@ -230,7 +230,7 @@ Response