diff --git a/build/install-dotnet.ps1 b/build/install-dotnet.ps1 index 11552a78..eeb52b70 100644 --- a/build/install-dotnet.ps1 +++ b/build/install-dotnet.ps1 @@ -1,8 +1,8 @@ -# Installs .NET 6 and .NET 7 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 \ No newline at end of file +&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 8.0 diff --git a/examples/ConfigStoreDemo/ConfigStoreDemo.csproj b/examples/ConfigStoreDemo/ConfigStoreDemo.csproj index 3b11aa18..caab5885 100644 --- a/examples/ConfigStoreDemo/ConfigStoreDemo.csproj +++ b/examples/ConfigStoreDemo/ConfigStoreDemo.csproj @@ -1,7 +1,7 @@  false - net7.0 + net8.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/ConsoleAppWithFailOver/ConsoleAppWithFailOver.csproj b/examples/ConsoleAppWithFailOver/ConsoleAppWithFailOver.csproj index dcff4691..ddcb2b93 100644 --- a/examples/ConsoleAppWithFailOver/ConsoleAppWithFailOver.csproj +++ b/examples/ConsoleAppWithFailOver/ConsoleAppWithFailOver.csproj @@ -2,13 +2,13 @@ Exe - net7.0 + net8.0 - - - + + + diff --git a/examples/ConsoleApplication/ConsoleApplication.csproj b/examples/ConsoleApplication/ConsoleApplication.csproj index 2bc2e9ec..bd4756fa 100644 --- a/examples/ConsoleApplication/ConsoleApplication.csproj +++ b/examples/ConsoleApplication/ConsoleApplication.csproj @@ -3,12 +3,12 @@ false Exe - net7.0 + net8.0 - - + + 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 c8b8b0a9..cb429e03 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 + 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 @@ -21,7 +21,7 @@ - 8.0.0-preview.2 + 8.0.0-preview.3 @@ -34,7 +34,8 @@ ..\..\AzureAppConfigurationRules.ruleset - True + true + true 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 55c32e2d..67df4997 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 + 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 @@ -24,7 +24,7 @@ - 8.0.0-preview.2 + 8.0.0-preview.3 @@ -37,7 +37,8 @@ ..\..\AzureAppConfigurationRules.ruleset - True + true + true 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 @@ -189,16 +190,16 @@ public async Task RefreshAsync(CancellationToken cancellationToken) try { // FeatureManagement assemblies may not be loaded on provider startup, so version information is gathered upon first refresh for tracing - EnsureFeatureManagementVersionInspected(); + 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; @@ -237,7 +238,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) { if (InitializationCacheExpires < utcNow) { - InitializationCacheExpires = utcNow.Add(MinCacheExpirationInterval); + InitializationCacheExpires = utcNow.Add(MinRefreshInterval); await InitializeAsync(clients, cancellationToken).ConfigureAwait(false); } @@ -266,7 +267,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; @@ -336,7 +337,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()) { @@ -350,9 +351,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)) @@ -387,7 +388,7 @@ await CallWithRequestTracing( // Invalidate the cached Key Vault secret (if any) for this ConfigurationSetting foreach (IKeyValueAdapter adapter in _options.Adapters) { - adapter.InvalidateCache(change.Current); + adapter.OnChangeDetected(change.Current); } } } @@ -398,13 +399,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); } } @@ -492,6 +493,12 @@ public async Task TryRefreshAsync(CancellationToken cancellationToken) return false; } + catch (FormatException fe) + { + _logger.LogWarning(LogHelper.BuildRefreshFailedDueToFormattingErrorMessage(fe.Message)); + + return false; + } return true; } @@ -536,16 +543,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; } } @@ -553,8 +560,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) { @@ -634,6 +641,7 @@ exception is KeyVaultReferenceException || exception is TimeoutException || exception is OperationCanceledException || exception is InvalidOperationException || + exception is FormatException || ((exception as AggregateException)?.InnerExceptions?.Any(e => e is RequestFailedException || e is OperationCanceledException) ?? false))) @@ -722,10 +730,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) @@ -733,7 +741,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); @@ -912,6 +920,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(); } @@ -955,7 +968,7 @@ 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 }; } @@ -980,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( @@ -991,6 +1003,27 @@ private async Task ExecuteWithFailOverPolicyAsync( Func> funcToExecute, CancellationToken cancellationToken = default) { + 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(); @@ -1011,6 +1044,8 @@ private async Task ExecuteWithFailOverPolicyAsync( T result = await funcToExecute(currentClient).ConfigureAwait(false); success = true; + _lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient); + return result; } catch (RequestFailedException rfe) @@ -1179,17 +1214,22 @@ private IEnumerable GetCurrentKeyValueCollection(string ke return currentKeyValues; } - private void EnsureFeatureManagementVersionInspected() + private void EnsureAssemblyInspected() { - if (!_isFeatureManagementVersionInspected) + if (!_isAssemblyInspected) { - _isFeatureManagementVersionInspected = true; + _isAssemblyInspected = true; if (_requestTracingEnabled && _requestTracingOptions != null) { _requestTracingOptions.FeatureManagementVersion = TracingUtils.GetAssemblyVersion(RequestTracingConstants.FeatureManagementAssemblyName); _requestTracingOptions.FeatureManagementAspNetCoreVersion = TracingUtils.GetAssemblyVersion(RequestTracingConstants.FeatureManagementAspNetCoreAssemblyName); + + if (TracingUtils.GetAssemblyVersion(RequestTracingConstants.SignalRAssemblyName) != null) + { + _requestTracingOptions.IsSignalRUsed = true; + } } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs index 5297507c..32ff2291 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 71a5f480..446fa714 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -36,11 +36,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 e49deb2c..8d0ec3b5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs @@ -29,22 +29,12 @@ public AzureKeyVaultKeyValueAdapter(AzureKeyVaultSecretProvider secretProvider) /// returns the keyname and actual value public async Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken) { - KeyVaultSecretReference secretRef; - - // Content validation - try - { - secretRef = JsonSerializer.Deserialize(setting.Value); - } - catch (JsonException e) - { - throw CreateKeyVaultReferenceException("Invalid Key Vault reference.", setting, e, null); - } + string secretRefUri = ParseSecretReferenceUri(setting); // Uri validation - if (string.IsNullOrEmpty(secretRef.Uri) || !Uri.TryCreate(secretRef.Uri, UriKind.Absolute, out Uri secretUri) || !KeyVaultSecretIdentifier.TryCreate(secretUri, out KeyVaultSecretIdentifier secretIdentifier)) + if (string.IsNullOrEmpty(secretRefUri) || !Uri.TryCreate(secretRefUri, UriKind.Absolute, out Uri secretUri) || !KeyVaultSecretIdentifier.TryCreate(secretUri, out KeyVaultSecretIdentifier secretIdentifier)) { - throw CreateKeyVaultReferenceException("Invalid Key vault secret identifier.", setting, null, secretRef); + throw CreateKeyVaultReferenceException("Invalid Key vault secret identifier.", setting, null, secretRefUri); } string secret; @@ -55,11 +45,11 @@ public async Task>> ProcessKeyValue(Con } catch (Exception e) when (e is UnauthorizedAccessException || (e.Source?.Equals(AzureIdentityAssemblyName, StringComparison.OrdinalIgnoreCase) ?? false)) { - throw CreateKeyVaultReferenceException(e.Message, setting, e, secretRef); + throw CreateKeyVaultReferenceException(e.Message, setting, e, secretRefUri); } catch (Exception e) when (e is RequestFailedException || ((e as AggregateException)?.InnerExceptions?.All(e => e is RequestFailedException) ?? false)) { - throw CreateKeyVaultReferenceException("Key vault error.", setting, e, secretRef); + throw CreateKeyVaultReferenceException("Key vault error.", setting, e, secretRefUri); } return new KeyValuePair[] @@ -68,7 +58,7 @@ public async Task>> ProcessKeyValue(Con }; } - KeyVaultReferenceException CreateKeyVaultReferenceException(string message, ConfigurationSetting setting, Exception inner, KeyVaultSecretReference secretRef = null) + KeyVaultReferenceException CreateKeyVaultReferenceException(string message, ConfigurationSetting setting, Exception inner, string secretRefUri = null) { return new KeyVaultReferenceException(message, inner) { @@ -76,7 +66,7 @@ KeyVaultReferenceException CreateKeyVaultReferenceException(string message, Conf Label = setting.Label, Etag = setting.ETag.ToString(), ErrorCode = (inner as RequestFailedException)?.ErrorCode, - SecretIdentifier = secretRef?.Uri + SecretIdentifier = secretRefUri }; } @@ -86,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) { @@ -98,9 +88,59 @@ public void InvalidateCache(ConfigurationSetting setting = null) } } + public void OnConfigUpdated() + { + return; + } + public bool NeedsRefresh() { return _secretProvider.ShouldRefreshKeyVaultSecrets(); } + + private string ParseSecretReferenceUri(ConfigurationSetting setting) + { + string secretRefUri = null; + + try + { + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(setting.Value)); + + if (reader.Read() && reader.TokenType != JsonTokenType.StartObject) + { + throw CreateKeyVaultReferenceException(ErrorMessages.InvalidKeyVaultReference, setting, null, null); + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + if (reader.GetString() == KeyVaultConstants.SecretReferenceUriJsonPropertyName) + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + secretRefUri = reader.GetString(); + } + else + { + throw CreateKeyVaultReferenceException(ErrorMessages.InvalidKeyVaultReference, setting, null, null); + } + } + else + { + reader.Skip(); + } + } + } + catch (JsonException e) + { + throw CreateKeyVaultReferenceException(ErrorMessages.InvalidKeyVaultReference, setting, e, null); + } + + return secretRefUri; + } } } \ No newline at end of file diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs index ec55e115..1309e58c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs @@ -6,5 +6,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault internal class KeyVaultConstants { public const string ContentType = "application/vnd.microsoft.appconfig.keyvaultref+json"; + + public const string SecretReferenceUriJsonPropertyName = "uri"; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultSecretReference.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultSecretReference.cs deleted file mode 100644 index 40efc2b1..00000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultSecretReference.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault -{ - internal class KeyVaultSecretReference - { - [JsonPropertyName("uri")] - public string Uri { get; set; } - } -} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs index 9ae71d2a..0a80932c 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 0c19cccf..c7974736 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs @@ -5,7 +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 InvalidKeyVaultReference = "Invalid Key Vault reference."; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs index 41c56ca3..86576a48 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs @@ -15,6 +15,7 @@ internal class LoggingConstants public const string RefreshFailedDueToKeyVaultError = "A refresh operation failed while resolving a Key Vault reference."; public const string PushNotificationUnregisteredEndpoint = "Ignoring the push notification received for the unregistered endpoint"; public const string FallbackClientLookupError = "Failed to perform fallback client lookup."; + public const string RefreshFailedDueToFormattingError = "A refresh operation failed due to a formatting error."; // Successful update, debug log level public const string RefreshKeyValueRead = "Key-value read from App Configuration."; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RefreshConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RefreshConstants.cs index 5805191a..965e380a 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.FromSeconds(1); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs index 9ab29637..9e542785 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs @@ -30,6 +30,12 @@ internal class RequestTracingConstants public const string KeyVaultConfiguredTag = "UsesKeyVault"; public const string KeyVaultRefreshConfiguredTag = "RefreshesKeyVault"; public const string ReplicaCountKey = "ReplicaCount"; + public const string FeatureFlagUsesTelemetryTag = "Telemetry"; + public const string FeatureFlagUsesSeedTag = "Seed"; + public const string FeatureFlagMaxVariantsKey = "MaxVariants"; + public const string FeatureFlagUsesVariantConfigurationReferenceTag = "ConfigRef"; + public const string FeatureFlagFeaturesKey = "FFFeatures"; + public const string SignalRUsedTag = "UsesSignalR"; public const string DiagnosticHeaderActivityName = "Azure.CustomDiagnosticHeaders"; public const string CorrelationContextHeader = "Correlation-Context"; @@ -37,5 +43,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/EventGridEventExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/EventGridEventExtensions.cs index bc91de1e..7ac04ca9 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/EventGridEventExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/EventGridEventExtensions.cs @@ -31,29 +31,59 @@ public static bool TryCreatePushNotification(this EventGridEvent eventGridEvent, if (Uri.TryCreate(eventGridEvent.Subject, UriKind.Absolute, out Uri resourceUri)) { - JsonElement eventGridEventData; + string syncToken = null; try { - eventGridEventData = JsonDocument.Parse(eventGridEvent.Data.ToString()).RootElement; + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(eventGridEvent.Data.ToString())); + + if (reader.Read() && reader.TokenType != JsonTokenType.StartObject) + { + return false; + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + if (reader.GetString() == SyncTokenPropertyName) + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + syncToken = reader.GetString(); + } + else + { + return false; + } + } + else + { + reader.Skip(); + } + } } catch (JsonException) { return false; } - if (eventGridEventData.ValueKind == JsonValueKind.Object && - eventGridEventData.TryGetProperty(SyncTokenPropertyName, out JsonElement syncTokenJson) && - syncTokenJson.ValueKind == JsonValueKind.String) + if (syncToken == null) { - pushNotification = new PushNotification() - { - SyncToken = syncTokenJson.GetString(), - EventType = eventGridEvent.EventType, - ResourceUri = resourceUri - }; - return true; + return false; } + + pushNotification = new PushNotification() + { + SyncToken = syncToken, + EventType = eventGridEvent.EventType, + ResourceUri = resourceUri + }; + + return true; } return false; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/Utf8JsonReaderExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/Utf8JsonReaderExtensions.cs deleted file mode 100644 index f5e2a01e..00000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/Utf8JsonReaderExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using System.Text.Json; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions -{ - internal static class Utf8JsonReaderExtensions - { - public static string ReadAsString(this Utf8JsonReader reader) - { - if (reader.Read()) - { - return reader.GetString(); - } - - return null; - } - } -} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/ClientFilter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/ClientFilter.cs index efd4023d..80aed990 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/ClientFilter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/ClientFilter.cs @@ -2,16 +2,13 @@ // Licensed under the MIT license. // using System.Text.Json; -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class ClientFilter { - [JsonPropertyName("name")] public string Name { get; set; } - [JsonPropertyName("parameters")] public JsonElement Parameters { get; set; } } -} +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureAllocation.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureAllocation.cs index f3817c97..0b2877e6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureAllocation.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureAllocation.cs @@ -2,28 +2,21 @@ // Licensed under the MIT license. // using System.Collections.Generic; -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class FeatureAllocation { - [JsonPropertyName("default_when_disabled")] public string DefaultWhenDisabled { get; set; } - [JsonPropertyName("default_when_enabled")] public string DefaultWhenEnabled { get; set; } - [JsonPropertyName("user")] public IEnumerable User { get; set; } - [JsonPropertyName("group")] public IEnumerable Group { get; set; } - [JsonPropertyName("percentile")] public IEnumerable Percentile { get; set; } - [JsonPropertyName("seed")] public string Seed { get; set; } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureConditions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureConditions.cs index 6927d310..ec29c199 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureConditions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureConditions.cs @@ -2,16 +2,13 @@ // Licensed under the MIT license. // using System.Collections.Generic; -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class FeatureConditions { - [JsonPropertyName("client_filters")] public List ClientFilters { get; set; } = new List(); - [JsonPropertyName("requirement_type")] public string RequirementType { get; set; } } -} +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs index dddf52c3..7fe6245e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs @@ -2,28 +2,21 @@ // Licensed under the MIT license. // using System.Collections.Generic; -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class FeatureFlag { - [JsonPropertyName("id")] public string Id { get; set; } - [JsonPropertyName("enabled")] public bool Enabled { get; set; } - [JsonPropertyName("conditions")] public FeatureConditions Conditions { get; set; } - [JsonPropertyName("variants")] public IEnumerable Variants { get; set; } - [JsonPropertyName("allocation")] public FeatureAllocation Allocation { get; set; } - [JsonPropertyName("telemetry")] public FeatureTelemetry Telemetry { get; set; } } -} +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index 202f4055..11f2fde1 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 65% rename from src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFilterTracing.cs rename to src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs index 27b59205..c1e4aaa5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFilterTracing.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs @@ -12,13 +12,12 @@ 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" }; @@ -29,18 +28,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 +75,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()) { @@ -85,7 +105,7 @@ public override string ToString() { if (sb.Length > 0) { - sb.Append(FilterTypeDelimiter); + sb.Append(RequestTracingConstants.Delimiter); } sb.Append(PercentageFilter); @@ -95,7 +115,7 @@ public override string ToString() { if (sb.Length > 0) { - sb.Append(FilterTypeDelimiter); + sb.Append(RequestTracingConstants.Delimiter); } sb.Append(TimeWindowFilter); @@ -105,7 +125,7 @@ public override string ToString() { if (sb.Length > 0) { - sb.Append(FilterTypeDelimiter); + sb.Append(RequestTracingConstants.Delimiter); } sb.Append(TargetingFilter); @@ -113,5 +133,37 @@ public override string ToString() return sb.ToString(); } + + public string CreateFeaturesString() + { + 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(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 index 3a9e6663..f39ca8cd 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureGroupAllocation.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureGroupAllocation.cs @@ -2,16 +2,13 @@ // Licensed under the MIT license. // using System.Collections.Generic; -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class FeatureGroupAllocation { - [JsonPropertyName("variant")] public string Variant { get; set; } - [JsonPropertyName("groups")] 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 7d53c234..c6d86d84 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs @@ -1,42 +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 Variants = "Variants"; - public const string Allocation = "Allocation"; - public const string User = "User"; - public const string Group = "Group"; - public const string Percentile = "Percentile"; - public const string Telemetry = "Telemetry"; - public const string Enabled = "Enabled"; - public const string Metadata = "Metadata"; - public const string RequirementType = "RequirementType"; - public const string Name = "Name"; - public const string Parameters = "Parameters"; - public const string Variant = "Variant"; - public const string ConfigurationValue = "ConfigurationValue"; - public const string ConfigurationReference = "ConfigurationReference"; - public const string StatusOverride = "StatusOverride"; - public const string DefaultWhenDisabled = "DefaultWhenDisabled"; - public const string DefaultWhenEnabled = "DefaultWhenEnabled"; - 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"; + + // 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"; - public const string Status = "Status"; - public const string AlwaysOnFilter = "AlwaysOn"; - public const string Conditional = "Conditional"; - public const string Disabled = "Disabled"; + + // 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 7b15f881..b562a904 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -16,55 +16,90 @@ 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, Uri endpoint, Logger logger, CancellationToken cancellationToken) { - FeatureFlag featureFlag; - try + FeatureFlag featureFlag = ParseFeatureFlag(setting.Key, setting.Value); + + var keyValues = new List>(); + + // Check if we need to process the feature flag using the microsoft schema + if ((featureFlag.Variants != null && featureFlag.Variants.Any()) || featureFlag.Allocation != null || featureFlag.Telemetry != null) { - featureFlag = JsonSerializer.Deserialize(setting.Value); + keyValues = ProcessMicrosoftSchemaFeatureFlag(featureFlag, setting, endpoint); } - catch (JsonException e) + else { - throw new FormatException(setting.Key, e); + 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>(); - string featureFlagPath = $"{FeatureManagementConstants.SectionName}:{featureFlag.Id}"; + if (string.IsNullOrEmpty(featureFlag.Id)) + { + return keyValues; + } + + string featureFlagPath = $"{FeatureManagementConstants.DotnetSchemaSectionName}:{featureFlag.Id}"; if (featureFlag.Enabled) { - keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.Status}", FeatureManagementConstants.Conditional)); - - //if (featureFlag.Conditions?.ClientFilters == null) - if (featureFlag.Conditions?.ClientFilters == null || !featureFlag.Conditions.ClientFilters.Any()) // workaround since we are not yet setting client filters to null + if (featureFlag.Conditions?.ClientFilters == null || !featureFlag.Conditions.ClientFilters.Any()) { - keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.EnabledFor}:{0}:{FeatureManagementConstants.Name}", FeatureManagementConstants.AlwaysOnFilter)); + keyValues.Add(new KeyValuePair(featureFlagPath, true.ToString())); } else { - // - // Conditionally on based on feature filters for (int i = 0; i < featureFlag.Conditions.ClientFilters.Count; i++) { ClientFilter clientFilter = featureFlag.Conditions.ClientFilters[i]; - _featureFilterTracing.UpdateFeatureFilterTracing(clientFilter.Name); + _featureFlagTracing.UpdateFeatureFilterTracing(clientFilter.Name); - string clientFiltersPath = $"{featureFlagPath}:{FeatureManagementConstants.EnabledFor}:{i}"; + string clientFiltersPath = $"{featureFlagPath}:{FeatureManagementConstants.DotnetSchemaEnabledFor}:{i}"; - keyValues.Add(new KeyValuePair($"{clientFiltersPath}:{FeatureManagementConstants.Name}", clientFilter.Name)); + keyValues.Add(new KeyValuePair($"{clientFiltersPath}:Name", clientFilter.Name)); foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(clientFilter.Parameters)) { - keyValues.Add(new KeyValuePair($"{clientFiltersPath}:{FeatureManagementConstants.Parameters}:{kvp.Key}", kvp.Value)); + keyValues.Add(new KeyValuePair($"{clientFiltersPath}:Parameters:{kvp.Key}", kvp.Value)); } } @@ -73,14 +108,67 @@ public Task>> ProcessKeyValue(Configura if (featureFlag.Conditions.RequirementType != null) { keyValues.Add(new KeyValuePair( - $"{featureFlagPath}:{FeatureManagementConstants.RequirementType}", + $"{featureFlagPath}:{FeatureManagementConstants.DotnetSchemaRequirementType}", featureFlag.Conditions.RequirementType)); } } } else { - keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.Status}", FeatureManagementConstants.Disabled)); + 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++) + { + 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)) + { + keyValues.Add(new KeyValuePair($"{clientFiltersPath}:{FeatureManagementConstants.Parameters}:{kvp.Key}", kvp.Value)); + } + } + + // + // process RequirementType only when filters are not empty + if (featureFlag.Conditions.RequirementType != null) + { + keyValues.Add(new KeyValuePair( + $"{featureFlagPath}:{FeatureManagementConstants.Conditions}:{FeatureManagementConstants.RequirementType}", + featureFlag.Conditions.RequirementType)); + } + } } if (featureFlag.Variants != null) @@ -101,6 +189,8 @@ public Task>> ProcessKeyValue(Configura if (featureVariant.ConfigurationReference != null) { + _featureFlagTracing.UsesVariantConfigurationReference = true; + keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.ConfigurationReference}", featureVariant.ConfigurationReference)); } @@ -111,6 +201,8 @@ public Task>> ProcessKeyValue(Configura i++; } + + _featureFlagTracing.NotifyMaxVariants(i); } if (featureFlag.Allocation != null) @@ -135,13 +227,13 @@ public Task>> ProcessKeyValue(Configura foreach (FeatureUserAllocation userAllocation in allocation.User) { - keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.User}:{i}:{FeatureManagementConstants.Variant}", userAllocation.Variant)); + 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.User}:{i}:{FeatureManagementConstants.Users}:{j}", user)); + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.UserAllocation}:{i}:{FeatureManagementConstants.Users}:{j}", user)); j++; } @@ -156,13 +248,13 @@ public Task>> ProcessKeyValue(Configura foreach (FeatureGroupAllocation groupAllocation in allocation.Group) { - keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.Group}:{i}:{FeatureManagementConstants.Variant}", groupAllocation.Variant)); + 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($"{allocationPath}:{FeatureManagementConstants.Group}:{i}:{FeatureManagementConstants.Groups}:{j}", group)); + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.GroupAllocation}:{i}:{FeatureManagementConstants.Groups}:{j}", group)); j++; } @@ -177,11 +269,11 @@ public Task>> ProcessKeyValue(Configura foreach (FeaturePercentileAllocation percentileAllocation in allocation.Percentile) { - keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.Percentile}:{i}:{FeatureManagementConstants.Variant}", percentileAllocation.Variant)); + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{i}:{FeatureManagementConstants.Variant}", percentileAllocation.Variant)); - keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.Percentile}:{i}:{FeatureManagementConstants.From}", percentileAllocation.From.ToString())); + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{i}:{FeatureManagementConstants.From}", percentileAllocation.From.ToString())); - keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.Percentile}:{i}:{FeatureManagementConstants.To}", percentileAllocation.To.ToString())); + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{i}:{FeatureManagementConstants.To}", percentileAllocation.To.ToString())); i++; } @@ -189,6 +281,8 @@ public Task>> ProcessKeyValue(Configura if (allocation.Seed != null) { + _featureFlagTracing.UsesSeed = true; + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.Seed}", allocation.Seed)); } } @@ -201,6 +295,8 @@ public Task>> ProcessKeyValue(Configura if (telemetry.Enabled) { + _featureFlagTracing.UsesTelemetry = true; + if (telemetry.Metadata != null) { foreach (KeyValuePair kvp in telemetry.Metadata) @@ -226,25 +322,984 @@ public Task>> ProcessKeyValue(Configura } } - return Task.FromResult>>(keyValues); + return keyValues; } - public bool CanProcess(ConfigurationSetting setting) + private FormatException CreateFeatureFlagFormatException(string jsonPropertyName, string settingKey, string foundJsonValueKind, string expectedJsonValueKind) { - string contentType = setting?.ContentType?.Split(';')[0].Trim(); + return new FormatException(string.Format( + ErrorMessages.FeatureFlagInvalidJsonProperty, + jsonPropertyName, + settingKey, + foundJsonValueKind, + expectedJsonValueKind)); + } - return string.Equals(contentType, FeatureManagementConstants.ContentType) || - setting.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker); + private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) + { + var featureFlag = new FeatureFlag(); + + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(settingValue)); + + try + { + if (reader.Read() && reader.TokenType != JsonTokenType.StartObject) + { + throw new FormatException(string.Format(ErrorMessages.FeatureFlagInvalidFormat, settingKey)); + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string propertyName = reader.GetString(); + + switch (propertyName) + { + case FeatureManagementConstants.Id: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureFlag.Id = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Id, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + case FeatureManagementConstants.Enabled: + { + if (reader.Read() && (reader.TokenType == JsonTokenType.False || reader.TokenType == JsonTokenType.True)) + { + featureFlag.Enabled = reader.GetBoolean(); + } + else if (reader.TokenType == JsonTokenType.String && bool.TryParse(reader.GetString(), out bool enabled)) + { + featureFlag.Enabled = enabled; + } + else + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Enabled, + settingKey, + reader.TokenType.ToString(), + $"{JsonTokenType.True}' or '{JsonTokenType.False}"); + } + + break; + } + + case FeatureManagementConstants.Conditions: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartObject) + { + featureFlag.Conditions = ParseFeatureConditions(ref reader, settingKey); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + 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()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + } + catch (JsonException e) + { + throw new FormatException(settingKey, e); + } + + return featureFlag; } - public void InvalidateCache(ConfigurationSetting setting = null) + private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, string settingKey) { - return; + var featureConditions = new FeatureConditions(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string conditionsPropertyName = reader.GetString(); + + switch (conditionsPropertyName) + { + case FeatureManagementConstants.ClientFilters: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartArray) + { + int i = 0; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.StartObject) + { + ClientFilter clientFilter = ParseClientFilter(ref reader, settingKey); + + if (clientFilter.Name != null || + (clientFilter.Parameters.ValueKind == JsonValueKind.Object && + clientFilter.Parameters.EnumerateObject().Any())) + { + featureConditions.ClientFilters.Add(clientFilter); + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + $"{FeatureManagementConstants.ClientFilters}[{i}]", + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + i++; + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.ClientFilters, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartArray.ToString()); + } + + break; + } + + case FeatureManagementConstants.RequirementType: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureConditions.RequirementType = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.RequirementType, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + + return featureConditions; } - public bool NeedsRefresh() + private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string settingKey) { - return false; + var clientFilter = new ClientFilter(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string clientFiltersPropertyName = reader.GetString(); + + switch (clientFiltersPropertyName) + { + case FeatureManagementConstants.Name: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + clientFilter.Name = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Name, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + case FeatureManagementConstants.Parameters: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartObject) + { + clientFilter.Parameters = JsonDocument.ParseValue(ref reader).RootElement; + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Parameters, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + + 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) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeaturePercentileAllocation.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeaturePercentileAllocation.cs index 8cb7e474..1d107dba 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeaturePercentileAllocation.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeaturePercentileAllocation.cs @@ -1,20 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // -using System.Collections.Generic; -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class FeaturePercentileAllocation { - [JsonPropertyName("variant")] public string Variant { get; set; } - [JsonPropertyName("from")] public double From { get; set; } - [JsonPropertyName("to")] 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 index c95178dd..09d8dd71 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureTelemetry.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureTelemetry.cs @@ -3,16 +3,13 @@ // using System.Collections.Generic; -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class FeatureTelemetry { - [JsonPropertyName("enabled")] public bool Enabled { get; set; } - [JsonPropertyName("metadata")] - public IReadOnlyDictionary Metadata { 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 index e781a5c6..fc403d6e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureUserAllocation.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureUserAllocation.cs @@ -2,16 +2,13 @@ // Licensed under the MIT license. // using System.Collections.Generic; -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class FeatureUserAllocation { - [JsonPropertyName("variant")] public string Variant { get; set; } - [JsonPropertyName("users")] 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 index c590f56b..87c5c0b1 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureVariant.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureVariant.cs @@ -2,22 +2,17 @@ // Licensed under the MIT license. // using System.Text.Json; -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class FeatureVariant { - [JsonPropertyName("name")] public string Name { get; set; } - [JsonPropertyName("configuration_value")] public JsonElement ConfigurationValue { get; set; } - [JsonPropertyName("configuration_reference")] public string ConfigurationReference { get; set; } - [JsonPropertyName("status_override")] public string StatusOverride { get; set; } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs index 8da0ee55..de13314e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs @@ -15,7 +15,9 @@ internal interface IKeyValueAdapter 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 fed06f11..574f64fa 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs @@ -30,10 +30,15 @@ public Task>> ProcessKeyValue(Configura } string rootJson = $"{{\"{setting.Key}\":{setting.Value}}}"; - JsonElement jsonData; + + List> keyValuePairs = new List>(); + try { - jsonData = JsonSerializer.Deserialize(rootJson); + using (JsonDocument document = JsonDocument.Parse(rootJson)) + { + keyValuePairs = new JsonFlattener().FlattenJson(document.RootElement); + } } catch (JsonException) { @@ -41,7 +46,7 @@ public Task>> ProcessKeyValue(Configura return Task.FromResult>>(new[] { new KeyValuePair(setting.Key, setting.Value) }); } - return Task.FromResult>>(new JsonFlattener().FlattenJson(jsonData)); + return Task.FromResult>>(keyValuePairs); } public bool CanProcess(ConfigurationSetting setting) @@ -90,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 113a1499..11442dcb 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs @@ -88,6 +88,10 @@ 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/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index d5b85ce4..31e1e854 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -14,9 +14,9 @@ - + - + @@ -34,7 +34,7 @@ - 8.0.0-preview.2 + 8.0.0-preview.3 @@ -47,7 +47,8 @@ ..\..\AzureAppConfigurationRules.ruleset - True + true + true 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 27af8fae..bbf9f667 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -33,9 +33,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. @@ -46,5 +46,10 @@ internal class RequestTracingOptions /// Version of the Microsoft.FeatureManagement.AspNetCore assembly, if present in the application. /// public string FeatureManagementAspNetCoreVersion { get; set; } + + /// + /// Flag to indicate whether Microsoft.AspNetCore.SignalR assembly is present in the application. + /// + public bool IsSignalRUsed { get; set; } = false; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs index 3ca5271f..6d5ae208 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.FilterTypeKey, 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) @@ -160,6 +191,11 @@ private static string CreateCorrelationContextHeader(RequestType requestType, Re correlationContextTags.Add(RequestTracingConstants.KeyVaultRefreshConfiguredTag); } + if (requestTracingOptions.IsSignalRUsed) + { + correlationContextTags.Add(RequestTracingConstants.SignalRUsedTag); + } + var sb = new StringBuilder(); foreach (KeyValuePair kvp in correlationContextKeyValues) diff --git a/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj b/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj index ef89659c..5d0a8d25 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 + net6.0;net8.0 8.0 false true @@ -10,11 +10,14 @@ - - - - - + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + 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 0340afd5..6ad45820 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 + net6.0;net8.0 8.0 false true @@ -10,11 +10,14 @@ - - - - - + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/Tests.AzureAppConfiguration/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/FailoverTests.cs index d302e407..4ddbd58f 100644 --- a/tests/Tests.AzureAppConfiguration/FailoverTests.cs +++ b/tests/Tests.AzureAppConfiguration/FailoverTests.cs @@ -23,7 +23,7 @@ public class FailOverTests contentType: "text"); [Fact] - public void FailOverTests_ReturnsAllClientsIfAllBackedOff() + public async Task FailOverTests_ReturnsAllClientsIfAllBackedOff() { // Arrange IConfigurationRefresher refresher = null; @@ -68,7 +68,7 @@ public void FailOverTests_ReturnsAllClientsIfAllBackedOff() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); options.ReplicaDiscoveryEnabled = false; @@ -85,7 +85,7 @@ public void FailOverTests_ReturnsAllClientsIfAllBackedOff() // Assert the inner request failed exceptions Assert.True((exception.InnerException as AggregateException)?.InnerExceptions?.All(e => e is RequestFailedException) ?? false); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); // The client manager should have called RefreshClients when all clients were backed off Assert.Equal(1, configClientManager.RefreshClientsCalled); @@ -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(); @@ -144,7 +144,7 @@ public void FailOverTests_PropagatesNonFailOverableExceptions() } [Fact] - public void FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() + public async Task FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() { // Arrange IConfigurationRefresher refresher = null; @@ -193,13 +193,13 @@ public void FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); }).Build(); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); // The first client should not have been called during refresh mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(0)); @@ -211,7 +211,7 @@ public void FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() // Wait for client 1 backoff to end Thread.Sleep(2500); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); // The first client should have been called now with refresh after the backoff time ends mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); @@ -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(); @@ -372,7 +377,7 @@ public void FailOverTests_FailOverOnKeyVaultReferenceException() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 8c2d2bbb..8e871ef2 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -15,9 +15,9 @@ using System.Collections.Generic; using System.Diagnostics.Tracing; using System.Linq; -using System.Net; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -88,7 +88,239 @@ public class FeatureManagementTests contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")); - List _featureFlagCollection = new List + List _nullOrMissingConditionsFeatureFlagCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "NullParameters", + value: @" + { + ""id"": ""NullParameters"", + ""description"": """", + ""display_name"": ""Null Parameters"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Filter"", + ""parameters"": null + } + ] + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "NullConditions", + value: @" + { + ""id"": ""NullConditions"", + ""description"": """", + ""display_name"": ""Null Conditions"", + ""enabled"": true, + ""conditions"": null + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "NullClientFilters", + value: @" + { + ""id"": ""NullClientFilters"", + ""description"": """", + ""display_name"": ""Null Client Filters"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": null + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "NoConditions", + value: @" + { + ""id"": ""NoConditions"", + ""description"": """", + ""display_name"": ""No Conditions"", + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "EmptyConditions", + value: @" + { + ""id"": ""EmptyConditions"", + ""description"": """", + ""display_name"": ""Empty Conditions"", + ""conditions"": {}, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "EmptyClientFilter", + value: @" + { + ""id"": ""EmptyClientFilter"", + ""description"": """", + ""display_name"": ""Empty Client Filter"", + ""conditions"": { + ""client_filters"": [ + {} + ] + }, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) + }; + + List _validFormatFeatureFlagCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "AdditionalProperty", + value: @" + { + ""id"": ""AdditionalProperty"", + ""description"": ""Should not throw an exception, additional properties are skipped."", + ""ignored_object"": { + ""id"": false + }, + ""enabled"": true, + ""conditions"": {} + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "DuplicateProperty", + value: @" + { + ""id"": ""DuplicateProperty"", + ""description"": ""Should not throw an exception, last of duplicate properties will win."", + ""enabled"": false, + ""enabled"": true, + ""conditions"": {} + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "AllowNullRequirementType", + value: @" + { + ""id"": ""AllowNullRequirementType"", + ""description"": ""Should not throw an exception, requirement type is allowed as null."", + ""enabled"": true, + ""conditions"": { + ""requirement_type"": null + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) + }; + + List _invalidFormatFeatureFlagCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "MissingClosingBracket1", + value: @" + { + ""id"": ""MissingClosingBracket1"", + ""description"": ""Should throw an exception, invalid end of json."", + ""enabled"": true, + ""conditions"": {} + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "MissingClosingBracket2", + value: @" + { + ""id"": ""MissingClosingBracket2"", + ""description"": ""Should throw an exception, invalid end of conditions object."", + ""conditions"": {, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "MissingClosingBracket3", + value: @" + { + ""id"": ""MissingClosingBracket3"", + ""description"": ""Should throw an exception, no closing bracket on client filters array."", + ""conditions"": { + ""client_filters"": [ + }, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "MissingOpeningBracket1", + value: @" + { + ""id"": ""MissingOpeningBracket1"", + ""description"": ""Should throw an exception, no opening bracket on conditions object."", + ""conditions"": }, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "MissingOpeningBracket2", + value: @" + { + ""id"": ""MissingOpeningBracket2"", + ""description"": ""Should throw an exception, no opening bracket on client filters array."", + ""conditions"": { + ""client_filters"": ] + }, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) + }; + + List _featureFlagCollection = new List { ConfigurationModelFactory.ConfigurationSetting( key: FeatureManagementConstants.FeatureFlagMarker + "App1_Feature1", @@ -188,95 +420,85 @@ public class FeatureManagementTests eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"), contentType: "text"); - private ConfigurationSetting _variantsKv1 = ConfigurationModelFactory.ConfigurationSetting( - key: FeatureManagementConstants.FeatureFlagMarker + "VariantsFeature1", - value: @" - { - ""id"": ""VariantsFeature1"", - ""description"": """", - ""display_name"": ""Variants Feature 1"", - ""enabled"": true, - ""conditions"": { - ""client_filters"": [ - ] - }, - ""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")); + 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")), - private ConfigurationSetting _variantsKv2 = ConfigurationModelFactory.ConfigurationSetting( - key: FeatureManagementConstants.FeatureFlagMarker + "VariantsFeature2", - value: @" + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "VariantsFeature2", + value: @" { ""id"": ""VariantsFeature2"", - ""description"": """", - ""display_name"": ""Variants Feature 2"", ""enabled"": false, - ""conditions"": { - ""client_filters"": [ - ] - }, ""variants"": [ { ""name"": ""ObjectVariant"", @@ -309,36 +531,98 @@ public class FeatureManagementTests } } ", - label: default, - contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", - eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")); + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), - private ConfigurationSetting _telemetryKv = ConfigurationModelFactory.ConfigurationSetting( - key: FeatureManagementConstants.FeatureFlagMarker + "TelemetryFeature", - value: @" - { - ""id"": ""TelemetryFeature"", - ""description"": """", - ""display_name"": ""Telemetry Feature"", - ""enabled"": true, - ""conditions"": { - ""client_filters"": [ - ] - }, - ""telemetry"": { + 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, - ""metadata"": { - ""Tags.Tag1"": ""Tag1Value"", - ""Tags.Tag2"": ""Tag2Value"" - } + ""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")); + ", + 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 CacheExpirationTime = TimeSpan.FromSeconds(1); + TimeSpan RefreshInterval = TimeSpan.FromSeconds(1); [Fact] public void UsesFeatureFlags() @@ -374,7 +658,76 @@ public void UsesFeatureFlags() } [Fact] - public void WatchesFeatureFlags() + public async Task WatchesFeatureFlags() + { + 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(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 }; @@ -384,13 +737,14 @@ public void WatchesFeatureFlags() mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(featureFlags)); + var cacheExpirationInterval = TimeSpan.FromSeconds(1); + 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.CacheExpirationInterval = cacheExpirationInterval); refresher = options.GetRefresher(); }) @@ -434,8 +788,8 @@ public void WatchesFeatureFlags() featureFlags.Add(_kv2); // Sleep to let the cache expire - Thread.Sleep(cacheExpirationTimeSpan); - refresher.RefreshAsync().Wait(); + Thread.Sleep(cacheExpirationInterval); + await refresher.RefreshAsync(); Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]); Assert.Equal("Chrome", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:0"]); @@ -443,9 +797,75 @@ public void 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 void SkipRefreshIfCacheNotExpired() + public async Task SkipRefreshIfCacheNotExpired() { var featureFlags = new List { _kv }; @@ -503,7 +923,7 @@ public void SkipRefreshIfCacheNotExpired() featureFlags.Add(_kv2); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]); Assert.Equal("Firefox", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:0"]); @@ -568,28 +988,27 @@ public void QueriesFeatureFlags() } [Fact] - public void UsesEtagForFeatureFlagRefresh() + public async Task UsesEtagForFeatureFlagRefresh() { var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .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); - refresher.TryRefreshAsync().Wait(); + await refresher.TryRefreshAsync(); mockClient.Verify(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Exactly(3)); } @@ -600,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())); @@ -613,14 +1031,14 @@ public void SelectFeatureFlags() options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); options.UseFeatureFlags(ff => { - ff.CacheExpirationInterval = cacheExpiration; + ff.SetRefreshInterval(RefreshInterval); ff.Select(featureFlagPrefix + "*", labelFilter); }); }) .Build(); - Assert.Equal("AlwaysOn", config["FeatureManagement:App1_Feature1:EnabledFor:0:Name"]); - Assert.Equal("Disabled", config["FeatureManagement:App1_Feature2:Status"]); + Assert.Equal("True", config["FeatureManagement:App1_Feature1"]); + Assert.Equal("False", config["FeatureManagement:App1_Feature2"]); // Verify that the feature flag that did not start with the specified prefix was not loaded Assert.Null(config["FeatureManagement:Feature1"]); @@ -630,6 +1048,124 @@ public void SelectFeatureFlags() Assert.Null(config["FeatureManagement:App2_Feature2"]); } + [Fact] + public void TestNullAndMissingValuesForConditions() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + var refreshInterval = TimeSpan.FromSeconds(1); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(_nullOrMissingConditionsFeatureFlagCollection)); + + var testClient = mockClient.Object; + + // Makes sure that adapter properly processes values and doesn't throw an exception + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); + options.UseFeatureFlags(ff => + { + ff.SetRefreshInterval(refreshInterval); + ff.Select(KeyFilter.Any); + }); + }) + .Build(); + + Assert.Null(config["FeatureManagement:NullConditions:EnabledFor"]); + Assert.Equal("Filter", config["FeatureManagement:NullParameters:EnabledFor:0:Name"]); + Assert.Null(config["FeatureManagement:NullParameters:EnabledFor:0:Parameters"]); + Assert.Null(config["FeatureManagement:NullClientFilters:EnabledFor"]); + Assert.Null(config["FeatureManagement:NoConditions:EnabledFor"]); + Assert.Null(config["FeatureManagement:EmptyConditions:EnabledFor"]); + Assert.Null(config["FeatureManagement:EmptyClientFilter:EnabledFor"]); + } + + [Fact] + public void InvalidFeatureFlagFormatsThrowFormatException() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + var refreshInterval = TimeSpan.FromSeconds(1); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns((Func)GetTestKeys); + + MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) + { + var copy = new List(); + var newSetting = _invalidFormatFeatureFlagCollection.FirstOrDefault(s => s.Key == selector.KeyFilter); + if (newSetting != null) + copy.Add(TestHelpers.CloneSetting(newSetting)); + return new MockAsyncPageable(copy); + }; + + var testClient = mockClient.Object; + + foreach (ConfigurationSetting setting in _invalidFormatFeatureFlagCollection) + { + void action() => new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Select("_"); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); + options.UseFeatureFlags(ff => + { + ff.SetRefreshInterval(refreshInterval); + ff.Select(setting.Key.Substring(FeatureManagementConstants.FeatureFlagMarker.Length)); + }); + }) + .Build(); + + // Each of the feature flags should throw an exception + Assert.Throws(action); + } + } + + [Fact] + public void AlternateValidFeatureFlagFormats() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + var cacheExpiration = TimeSpan.FromSeconds(1); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns((Func)GetTestKeys); + + MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) + { + var copy = new List(); + var newSetting = _validFormatFeatureFlagCollection.FirstOrDefault(s => s.Key == selector.KeyFilter); + if (newSetting != null) + copy.Add(TestHelpers.CloneSetting(newSetting)); + return new MockAsyncPageable(copy); + }; + + var testClient = mockClient.Object; + + foreach (ConfigurationSetting setting in _validFormatFeatureFlagCollection) + { + string flagKey = setting.Key.Substring(FeatureManagementConstants.FeatureFlagMarker.Length); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Select("_"); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); + options.UseFeatureFlags(ff => + { + ff.CacheExpirationInterval = cacheExpiration; + ff.Select(flagKey); + }); + }) + .Build(); + + // None of the feature flags should throw an exception, and the flag should be loaded like normal + Assert.Equal("True", config[$"FeatureManagement:{flagKey}"]); + } + } + [Fact] public void MultipleSelectsInSameUseFeatureFlags() { @@ -662,10 +1198,10 @@ public void MultipleSelectsInSameUseFeatureFlags() }) .Build(); - Assert.Equal("AlwaysOn", config["FeatureManagement:App1_Feature1:EnabledFor:0:Name"]); - Assert.Equal("Disabled", config["FeatureManagement:App1_Feature2:Status"]); - Assert.Equal("Disabled", config["FeatureManagement:App2_Feature1:Status"]); - Assert.Equal("AlwaysOn", config["FeatureManagement:App2_Feature2:EnabledFor:0:Name"]); + Assert.Equal("True", config["FeatureManagement:App1_Feature1"]); + Assert.Equal("False", config["FeatureManagement:App1_Feature2"]); + Assert.Equal("False", config["FeatureManagement:App2_Feature1"]); + Assert.Equal("True", config["FeatureManagement:App2_Feature2"]); // Verify that the feature flag that did not start with the specified prefix was not loaded Assert.Null(config["FeatureManagement:Feature1"]); @@ -703,7 +1239,7 @@ public void KeepSelectorPrecedenceAfterDedup() }) .Build(); // label: App1_Label has higher precedence - Assert.Equal("AlwaysOn", config["FeatureManagement:Feature1:EnabledFor:0:Name"]); + Assert.Equal("True", config["FeatureManagement:Feature1"]); } [Fact] @@ -754,7 +1290,7 @@ public void MultipleCallsToUseFeatureFlags() .Returns(() => { return new MockAsyncPageable(_featureFlagCollection.Where(s => - (s.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker + prefix1) && s.Label == label1) || + (s.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker + prefix1) && s.Label == label1) || (s.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker + prefix2) && s.Label == label2)).ToList()); }); @@ -775,10 +1311,10 @@ public void MultipleCallsToUseFeatureFlags() }) .Build(); - Assert.Equal("AlwaysOn", config["FeatureManagement:App1_Feature1:EnabledFor:0:Name"]); - Assert.Equal("Disabled", config["FeatureManagement:App1_Feature2:Status"]); - Assert.Equal("Disabled", config["FeatureManagement:App2_Feature1:Status"]); - Assert.Equal("AlwaysOn", config["FeatureManagement:App2_Feature2:EnabledFor:0:Name"]); + Assert.Equal("True", config["FeatureManagement:App1_Feature1"]); + Assert.Equal("False", config["FeatureManagement:App1_Feature2"]); + Assert.Equal("False", config["FeatureManagement:App2_Feature1"]); + Assert.Equal("True", config["FeatureManagement:App2_Feature2"]); // Verify that the feature flag that did not start with the specified prefix was not loaded Assert.Null(config["FeatureManagement:Feature1"]); @@ -819,17 +1355,17 @@ public void MultipleCallsToUseFeatureFlagsWithSelectAndLabel() .Build(); // Loaded from prefix1 and label1 - Assert.Equal("AlwaysOn", config["FeatureManagement:App1_Feature1:EnabledFor:0:Name"]); - Assert.Equal("Disabled", config["FeatureManagement:App1_Feature2:Status"]); + Assert.Equal("True", config["FeatureManagement:App1_Feature1"]); + Assert.Equal("False", config["FeatureManagement:App1_Feature2"]); // Loaded from label2 - Assert.Equal("Disabled", config["FeatureManagement:App2_Feature1:Status"]); - Assert.Equal("AlwaysOn", config["FeatureManagement:App2_Feature2:EnabledFor:0:Name"]); - Assert.Equal("AlwaysOn", config["FeatureManagement:Feature1:EnabledFor:0:Name"]); + Assert.Equal("False", config["FeatureManagement:App2_Feature1"]); + Assert.Equal("True", config["FeatureManagement:App2_Feature2"]); + Assert.Equal("True", config["FeatureManagement:Feature1"]); } [Fact] - public void DifferentCacheExpirationsForMultipleFeatureFlagRegistrations() + public async Task DifferentCacheExpirationsForMultipleFeatureFlagRegistrations() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -837,8 +1373,8 @@ public void 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); @@ -856,12 +1392,12 @@ public void 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); }); @@ -869,10 +1405,10 @@ public void DifferentCacheExpirationsForMultipleFeatureFlagRegistrations() }) .Build(); - Assert.Equal("AlwaysOn", config["FeatureManagement:App1_Feature1:EnabledFor:0:Name"]); - Assert.Equal("Disabled", config["FeatureManagement:App1_Feature2:Status"]); - Assert.Equal("Disabled", config["FeatureManagement:App2_Feature1:Status"]); - Assert.Equal("AlwaysOn", config["FeatureManagement:App2_Feature2:EnabledFor:0:Name"]); + Assert.Equal("True", config["FeatureManagement:App1_Feature1"]); + Assert.Equal("False", config["FeatureManagement:App1_Feature2"]); + Assert.Equal("False", config["FeatureManagement:App2_Feature1"]); + Assert.Equal("True", config["FeatureManagement:App2_Feature2"]); // update the value of App1_Feature1 feature flag with label1 featureFlagCollection[0] = ConfigurationModelFactory.ConfigurationSetting( @@ -913,28 +1449,28 @@ public void 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); - refresher.RefreshAsync().Wait(); + // 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"]); Assert.Equal("Chrome", config["FeatureManagement:App1_Feature1:EnabledFor:0:Parameters:AllowedBrowsers:0"]); Assert.Equal("Edge", config["FeatureManagement:App1_Feature1:EnabledFor:0:Parameters:AllowedBrowsers:1"]); - Assert.Equal("Disabled", config["FeatureManagement:App1_Feature2:Status"]); - Assert.Equal("Disabled", config["FeatureManagement:App2_Feature1:Status"]); - Assert.Equal("AlwaysOn", config["FeatureManagement:App2_Feature2:EnabledFor:0:Name"]); + Assert.Equal("False", config["FeatureManagement:App1_Feature2"]); + Assert.Equal("False", config["FeatureManagement:App2_Feature1"]); + Assert.Equal("True", config["FeatureManagement:App2_Feature2"]); // even though App2_Feature3 feature flag has been added, its value should not be loaded in config because label2 cache has not expired Assert.Null(config["FeatureManagement:App2_Feature3"]); } [Fact] - public void 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); @@ -949,24 +1485,24 @@ public void 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(); }) .Build(); - Assert.Equal("AlwaysOn", config["FeatureManagement:App1_Feature1:EnabledFor:0:Name"]); - Assert.Equal("Disabled", config["FeatureManagement:App1_Feature2:Status"]); - Assert.Equal("Disabled", config["FeatureManagement:App2_Feature1:Status"]); - Assert.Equal("AlwaysOn", config["FeatureManagement:App2_Feature2:EnabledFor:0:Name"]); - Assert.Equal("AlwaysOn", config["FeatureManagement:Feature1:EnabledFor:0:Name"]); + Assert.Equal("True", config["FeatureManagement:App1_Feature1"]); + Assert.Equal("False", config["FeatureManagement:App1_Feature2"]); + Assert.Equal("False", config["FeatureManagement:App2_Feature1"]); + Assert.Equal("True", config["FeatureManagement:App2_Feature2"]); + Assert.Equal("True", config["FeatureManagement:Feature1"]); // update the value of App1_Feature1 feature flag with label1 featureFlagCollection[0] = ConfigurationModelFactory.ConfigurationSetting( @@ -991,26 +1527,25 @@ public void OverwrittenCacheExpirationForSameFeatureFlagRegistrations() contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); - Thread.Sleep(cacheExpiration1); - refresher.RefreshAsync().Wait(); + 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. - Assert.Equal("AlwaysOn", config["FeatureManagement:App1_Feature1:EnabledFor:0:Name"]); - Assert.Equal("Disabled", config["FeatureManagement:App1_Feature2:Status"]); - Assert.Equal("Disabled", config["FeatureManagement:App2_Feature1:Status"]); - Assert.Equal("AlwaysOn", config["FeatureManagement:App2_Feature2:EnabledFor:0:Name"]); - Assert.Equal("AlwaysOn", config["FeatureManagement:Feature1:EnabledFor:0:Name"]); + // 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"]); + Assert.Equal("True", config["FeatureManagement:App2_Feature2"]); + Assert.Equal("True", config["FeatureManagement:Feature1"]); } [Fact] - public void SelectAndRefreshSingleFeatureFlag() + public async Task SelectAndRefreshSingleFeatureFlag() { var mockResponse = new Mock(); 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); @@ -1027,7 +1562,7 @@ public void SelectAndRefreshSingleFeatureFlag() options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.UseFeatureFlags(ff => { - ff.CacheExpirationInterval = cacheExpiration; + ff.SetRefreshInterval(RefreshInterval); ff.Select(prefix1, label1); }); @@ -1035,7 +1570,7 @@ public void SelectAndRefreshSingleFeatureFlag() }) .Build(); - Assert.Equal("Disabled", config["FeatureManagement:Feature1:Status"]); + Assert.Equal("False", config["FeatureManagement:Feature1"]); // update the value of Feature1 feature flag with App1_Label featureFlagCollection[2] = ConfigurationModelFactory.ConfigurationSetting( @@ -1060,9 +1595,9 @@ public void 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); - refresher.RefreshAsync().Wait(); + // 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"]); Assert.Equal("Chrome", config["FeatureManagement:Feature1:EnabledFor:0:Parameters:AllowedBrowsers:0"]); @@ -1070,7 +1605,7 @@ public void SelectAndRefreshSingleFeatureFlag() } [Fact] - public void ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefresh() + public async Task ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefresh() { IConfigurationRefresher refresher = null; var featureFlags = new List { _kv2 }; @@ -1101,7 +1636,7 @@ public void ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefresh() .AddAzureAppConfiguration(options => { options.ClientManager = mockClientManager; - options.UseFeatureFlags(o => o.CacheExpirationInterval = CacheExpirationTime); + options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval)); refresher = options.GetRefresher(); }) .Build(); @@ -1129,15 +1664,15 @@ public void ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefresh() contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + 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); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Null(config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); Assert.Contains(LogHelper.BuildFeatureFlagReadMessage("myFeature1", null, TestHelpers.PrimaryConfigStoreEndpoint.ToString().TrimEnd('/')), verboseInvocation); @@ -1145,7 +1680,7 @@ public void ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefresh() } [Fact] - public void ValidateFeatureFlagsUnchangedLogged() + public async Task ValidateFeatureFlagsUnchangedLogged() { IConfigurationRefresher refresher = null; var featureFlags = new List { _kv2 }; @@ -1177,11 +1712,11 @@ public void 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(); }) @@ -1190,14 +1725,14 @@ public void ValidateFeatureFlagsUnchangedLogged() Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]); Assert.Contains(LogHelper.BuildFeatureFlagsUnchangedMessage(TestHelpers.PrimaryConfigStoreEndpoint.ToString().TrimEnd('/')), verboseInvocation); } [Fact] - public void MapTransformFeatureFlagWithRefresh() + public async Task MapTransformFeatureFlagWithRefresh() { ConfigurationSetting _kv = ConfigurationModelFactory.ConfigurationSetting( key: FeatureManagementConstants.FeatureFlagMarker + "myFeature", @@ -1245,9 +1780,9 @@ public void 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") @@ -1302,8 +1837,8 @@ public void MapTransformFeatureFlagWithRefresh() contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("newValue1", config["TestKey1"]); Assert.Equal("NoUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); @@ -1312,17 +1847,11 @@ public void MapTransformFeatureFlagWithRefresh() [Fact] public void WithVariants() { - var featureFlags = new List() - { - _variantsKv1, - _variantsKv2 - }; - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(featureFlags)); + .Returns(new MockAsyncPageable(_variantFeatureFlagCollection)); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => @@ -1332,93 +1861,79 @@ public void WithVariants() }) .Build(); - Assert.Equal("AlwaysOn", config["FeatureManagement:VariantsFeature1:EnabledFor:0:Name"]); - Assert.Equal("Big", config["FeatureManagement:VariantsFeature1:Variants:0:Name"]); - Assert.Equal("600px", config["FeatureManagement:VariantsFeature1:Variants:0:ConfigurationValue"]); - Assert.Equal("Small", config["FeatureManagement:VariantsFeature1:Variants:1:Name"]); - Assert.Equal("ShoppingCart:Small", config["FeatureManagement:VariantsFeature1:Variants:1:ConfigurationReference"]); - Assert.Equal("Disabled", config["FeatureManagement:VariantsFeature1:Variants:1:StatusOverride"]); - Assert.Equal("Small", config["FeatureManagement:VariantsFeature1:Allocation:DefaultWhenDisabled"]); - Assert.Equal("Small", config["FeatureManagement:VariantsFeature1:Allocation:DefaultWhenEnabled"]); - Assert.Equal("Big", config["FeatureManagement:VariantsFeature1:Allocation:User:0:Variant"]); - Assert.Equal("Marsha", config["FeatureManagement:VariantsFeature1:Allocation:User:0:Users:0"]); - Assert.Equal("John", config["FeatureManagement:VariantsFeature1:Allocation:User:0:Users:1"]); - Assert.Equal("Small", config["FeatureManagement:VariantsFeature1:Allocation:User:1:Variant"]); - Assert.Equal("Alice", config["FeatureManagement:VariantsFeature1:Allocation:User:1:Users:0"]); - Assert.Equal("Bob", config["FeatureManagement:VariantsFeature1:Allocation:User:1:Users:1"]); - Assert.Equal("Big", config["FeatureManagement:VariantsFeature1:Allocation:Group:0:Variant"]); - Assert.Equal("Ring1", config["FeatureManagement:VariantsFeature1:Allocation:Group:0:Groups:0"]); - Assert.Equal("Small", config["FeatureManagement:VariantsFeature1:Allocation:Group:1:Variant"]); - Assert.Equal("Ring2", config["FeatureManagement:VariantsFeature1:Allocation:Group:1:Groups:0"]); - Assert.Equal("Ring3", config["FeatureManagement:VariantsFeature1:Allocation:Group:1:Groups:1"]); - Assert.Equal("Big", config["FeatureManagement:VariantsFeature1:Allocation:Percentile:0:Variant"]); - Assert.Equal("0", config["FeatureManagement:VariantsFeature1:Allocation:Percentile:0:From"]); - Assert.Equal("50", config["FeatureManagement:VariantsFeature1:Allocation:Percentile:0:To"]); - Assert.Equal("Small", config["FeatureManagement:VariantsFeature1:Allocation:Percentile:1:Variant"]); - Assert.Equal("50", config["FeatureManagement:VariantsFeature1:Allocation:Percentile:1:From"]); - Assert.Equal("100", config["FeatureManagement:VariantsFeature1:Allocation:Percentile:1:To"]); - Assert.Equal("13992821", config["FeatureManagement:VariantsFeature1:Allocation:Seed"]); - - Assert.Equal("Disabled", config["FeatureManagement:VariantsFeature2:Status"]); - Assert.Equal("ObjectVariant", config["FeatureManagement:VariantsFeature2:Variants:0:Name"]); - Assert.Equal("Value1", config["FeatureManagement:VariantsFeature2:Variants:0:ConfigurationValue:Key1"]); - Assert.Equal("Value2", config["FeatureManagement:VariantsFeature2:Variants:0:ConfigurationValue:Key2:InsideKey2"]); - Assert.Equal("NumberVariant", config["FeatureManagement:VariantsFeature2:Variants:1:Name"]); - Assert.Equal("100", config["FeatureManagement:VariantsFeature2:Variants:1:ConfigurationValue"]); - Assert.Equal("NullVariant", config["FeatureManagement:VariantsFeature2:Variants:2:Name"]); - Assert.Equal("", config["FeatureManagement:VariantsFeature2:Variants:2:ConfigurationValue"]); + 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("FeatureManagement:VariantsFeature2:Variants:2") + .GetSection("feature_management:feature_flags:1:variants:2") .AsEnumerable() .ToDictionary(x => x.Key, x => x.Value) - .ContainsKey("FeatureManagement:VariantsFeature2:Variants:2:ConfigurationValue")); - Assert.Equal("MissingValueVariant", config["FeatureManagement:VariantsFeature2:Variants:3:Name"]); - Assert.Null(config["FeatureManagement:VariantsFeature2:Variants:3:ConfigurationValue"]); + .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("FeatureManagement:VariantsFeature2:Variants:3") + .GetSection("feature_management:feature_flags:1:variants:3") .AsEnumerable() .ToDictionary(x => x.Key, x => x.Value) - .ContainsKey("FeatureManagement:VariantsFeature2:Variants:3:ConfigurationValue")); - Assert.Equal("BooleanVariant", config["FeatureManagement:VariantsFeature2:Variants:4:Name"]); - Assert.Equal("True", config["FeatureManagement:VariantsFeature2:Variants:4:ConfigurationValue"]); - Assert.Equal("ObjectVariant", config["FeatureManagement:VariantsFeature2:Allocation:DefaultWhenDisabled"]); - Assert.Equal("ObjectVariant", config["FeatureManagement:VariantsFeature2:Allocation:DefaultWhenEnabled"]); - } - - [Fact] - public void WithStatus() - { - var mockResponse = new Mock(); - var mockClient = new Mock(MockBehavior.Strict); - - mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(_featureFlagCollection)); - - var config = new ConfigurationBuilder() - .AddAzureAppConfiguration(options => - { - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.UseFeatureFlags(); - }) - .Build(); - - Assert.Equal("Disabled", config["FeatureManagement:App1_Feature2:Status"]); - Assert.Equal("Conditional", config["FeatureManagement:Feature1:Status"]); + .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 featureFlags = new List() - { - _telemetryKv - }; - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(featureFlags)); + .Returns(new MockAsyncPageable(_telemetryFeatureFlagCollection)); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => @@ -1429,16 +1944,17 @@ public void WithTelemetry() }) .Build(); - Assert.Equal("True", config["FeatureManagement:TelemetryFeature:Telemetry:Enabled"]); - Assert.Equal("Tag1Value", config["FeatureManagement:TelemetryFeature:Telemetry:Metadata:Tags.Tag1"]); - Assert.Equal("Tag2Value", config["FeatureManagement:TelemetryFeature:Telemetry:Metadata:Tags.Tag2"]); - Assert.Equal("c3c231fd-39a0-4cb6-3237-4614474b92c1", config["FeatureManagement:TelemetryFeature:Telemetry:Metadata:ETag"]); + 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}TelemetryFeature\nlabel")); + featureFlagIdHash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes($"{FeatureManagementConstants.FeatureFlagMarker}TelemetryFeature1\nlabel")); } string featureFlagId = Convert.ToBase64String(featureFlagIdHash) @@ -1446,8 +1962,12 @@ public void WithTelemetry() .Replace('+', '-') .Replace('/', '_'); - Assert.Equal(featureFlagId, config["FeatureManagement:TelemetryFeature:Telemetry:Metadata:FeatureFlagId"]); - Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}TelemetryFeature?label=label", config["FeatureManagement:TelemetryFeature:Telemetry:Metadata:FeatureFlagReference"]); + 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"]); } @@ -1469,9 +1989,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(); @@ -1488,10 +2008,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) @@ -1504,18 +2074,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/JsonContentTypeTests.cs b/tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs index 7723a612..91cb03a8 100644 --- a/tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs +++ b/tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs @@ -28,20 +28,15 @@ public void JsonContentTypeTests_CompareJsonSettingsBetweenAppConfigAndJsonFile( var appconfigSettings = new ConfigurationBuilder() .AddAzureAppConfiguration(options => options.ClientManager = mockClientManager) - .Build() - .AsEnumerable(); + .Build(); var jsonSettings = new ConfigurationBuilder() .AddJsonFile(jsonFilePath) - .Build() - .AsEnumerable(); - - Assert.Equal(jsonSettings.Count(), appconfigSettings.Count()); + .Build(); - foreach (KeyValuePair jsonSetting in jsonSettings) + foreach (KeyValuePair jsonSetting in jsonSettings.AsEnumerable()) { - KeyValuePair appconfigSetting = appconfigSettings.SingleOrDefault(x => x.Key == jsonSetting.Key); - Assert.Equal(jsonSetting, appconfigSetting); + Assert.Equal(jsonSettings.GetSection(jsonSetting.Key).Value, appconfigSettings.GetSection(jsonSetting.Key).Value); } } diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs index 9a80a350..6108519c 100644 --- a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs @@ -12,6 +12,7 @@ using Moq; using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -82,6 +83,91 @@ public class KeyVaultReferenceTests contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), }; + List _invalidJsonKvCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key:"MissingClosingBracket", + value: @" + { + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), + + ConfigurationModelFactory.ConfigurationSetting( + key:"MissingOpeningBracket", + value: @" + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), + + ConfigurationModelFactory.ConfigurationSetting( + key:"MissingUriInRootJson", + value: @" + { + { + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + } + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), + + ConfigurationModelFactory.ConfigurationSetting( + key:"UriValueInsideObject", + value: @" + { + { + ""uri"": { + ""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + } + } + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8") + }; + + List _validJsonKvCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key:"AdditionalProperty1", + value: @" + { + ""additional_property"":""additional_property"", + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), + + ConfigurationModelFactory.ConfigurationSetting( + key:"AdditionalProperty2", + value: @" + { + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"", + ""additional_property"": { + ""inside_property"": ""inside_property"" + } + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), + + ConfigurationModelFactory.ConfigurationSetting( + key:"DuplicateUri", + value: @" + { + ""uri"":""https://keyvault-theclassics.vault.azure.net/certificates/TestCertificate"", + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8") + }; + [Fact] public void NotSecretIdentifierURI() { @@ -424,7 +510,8 @@ public void DoesNotThrowKeyVaultExceptionWhenProviderIsOptional() .Returns(true); 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 => @@ -597,10 +684,10 @@ public void ThrowsWhenSecretRefreshIntervalIsTooShort() } [Fact] - public void SecretIsReturnedFromCacheIfSecretCacheHasNotExpired() + 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); @@ -646,7 +733,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("Sentinel") - .SetCacheExpiration(cacheExpirationTime); + .SetRefreshInterval(refreshInterval); }); refresher = options.GetRefresher(); @@ -658,8 +745,8 @@ Response GetIfChanged(ConfigurationSetting setting, bool o // Update sentinel key-value sentinelKv.Value = "Value2"; - Thread.Sleep(cacheExpirationTime); - refresher.RefreshAsync().Wait(); + Thread.Sleep(refreshInterval); + await refresher.RefreshAsync(); Assert.Equal("Value2", config["Sentinel"]); Assert.Equal(_secretValue, config[_kv.Key]); @@ -670,10 +757,10 @@ Response GetIfChanged(ConfigurationSetting setting, bool o } [Fact] - public void CachedSecretIsInvalidatedWhenRefreshAllIsTrue() + public async Task CachedSecretIsInvalidatedWhenRefreshAllIsTrue() { IConfigurationRefresher refresher = null; - TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(1); + TimeSpan refreshInterval = TimeSpan.FromSeconds(1); var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -718,7 +805,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("Sentinel", refreshAll: true) - .SetCacheExpiration(cacheExpirationTime); + .SetRefreshInterval(refreshInterval); }); refresher = options.GetRefresher(); @@ -730,8 +817,8 @@ Response GetIfChanged(ConfigurationSetting setting, bool o // Update sentinel key-value to trigger refresh operation sentinelKv.Value = "Value2"; - Thread.Sleep(cacheExpirationTime); - refresher.RefreshAsync().Wait(); + Thread.Sleep(refreshInterval); + await refresher.RefreshAsync(); Assert.Equal("Value2", config["Sentinel"]); Assert.Equal(_secretValue, config[_kv.Key]); @@ -742,10 +829,10 @@ Response GetIfChanged(ConfigurationSetting setting, bool o } [Fact] - public void SecretIsReloadedFromKeyVaultWhenCacheExpires() + public async Task SecretIsReloadedFromKeyVaultWhenCacheExpires() { IConfigurationRefresher refresher = null; - TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(1); + TimeSpan refreshInterval = TimeSpan.FromSeconds(1); var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -765,7 +852,7 @@ public void SecretIsReloadedFromKeyVaultWhenCacheExpires() options.ConfigureKeyVault(kv => { kv.Register(mockSecretClient.Object); - kv.SetSecretRefreshInterval(_kv.Key, cacheExpirationTime); + kv.SetSecretRefreshInterval(_kv.Key, refreshInterval); }); refresher = options.GetRefresher(); @@ -775,8 +862,8 @@ public void SecretIsReloadedFromKeyVaultWhenCacheExpires() Assert.Equal(_secretValue, config[_kv.Key]); // Sleep to let the secret cache expire - Thread.Sleep(cacheExpirationTime); - refresher.RefreshAsync().Wait(); + Thread.Sleep(refreshInterval); + await refresher.RefreshAsync(); Assert.Equal(_secretValue, config[_kv.Key]); @@ -785,10 +872,10 @@ public void SecretIsReloadedFromKeyVaultWhenCacheExpires() } [Fact] - public void SecretsWithDefaultRefreshInterval() + public async Task SecretsWithDefaultRefreshInterval() { IConfigurationRefresher refresher = null; - TimeSpan shortCacheExpirationTime = TimeSpan.FromSeconds(1); + TimeSpan shortRefreshInterval = TimeSpan.FromSeconds(1); var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -808,7 +895,7 @@ public void SecretsWithDefaultRefreshInterval() options.ConfigureKeyVault(kv => { kv.Register(mockSecretClient.Object); - kv.SetSecretRefreshInterval(shortCacheExpirationTime); + kv.SetSecretRefreshInterval(shortRefreshInterval); }); refresher = options.GetRefresher(); @@ -819,8 +906,8 @@ public void SecretsWithDefaultRefreshInterval() Assert.Equal(_secretValue, config["TK2"]); // Sleep to let the secret cache expire for both secrets - Thread.Sleep(shortCacheExpirationTime); - refresher.RefreshAsync().Wait(); + Thread.Sleep(shortRefreshInterval); + await refresher.RefreshAsync(); Assert.Equal(_secretValue, config["TK1"]); Assert.Equal(_secretValue, config["TK2"]); @@ -830,11 +917,11 @@ public void SecretsWithDefaultRefreshInterval() } [Fact] - public void SecretsWithDifferentRefreshIntervals() + public async Task SecretsWithDifferentRefreshIntervals() { IConfigurationRefresher refresher = null; - TimeSpan shortCacheExpirationTime = TimeSpan.FromSeconds(1); - TimeSpan longCacheExpirationTime = TimeSpan.FromDays(1); + TimeSpan shortRefreshInterval = TimeSpan.FromSeconds(1); + TimeSpan longRefreshInterval = TimeSpan.FromDays(1); var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -854,8 +941,8 @@ public void 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(); @@ -866,8 +953,8 @@ public void SecretsWithDifferentRefreshIntervals() Assert.Equal(_secretValue, config["TK2"]); // Sleep to let the secret cache expire for one secret - Thread.Sleep(shortCacheExpirationTime); - refresher.RefreshAsync().Wait(); + Thread.Sleep(shortRefreshInterval); + await refresher.RefreshAsync(); Assert.Equal(_secretValue, config["TK1"]); Assert.Equal(_secretValue, config["TK2"]); @@ -875,5 +962,95 @@ public void SecretsWithDifferentRefreshIntervals() // Validate that 3 calls were made to fetch secrets from KeyVault because the secret cache had expired for only one secret. mockSecretClient.Verify(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(3)); } + + + [Fact] + public void ThrowsWhenInvalidKeyVaultSecretReferenceJson() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + var cacheExpiration = TimeSpan.FromSeconds(1); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns((Func)GetTestKeys); + + var mockSecretClient = new Mock(MockBehavior.Strict); + mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net")); + + MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) + { + var copy = new List(); + var newSetting = _invalidJsonKvCollection.FirstOrDefault(s => s.Key == selector.KeyFilter); + if (newSetting != null) + copy.Add(TestHelpers.CloneSetting(newSetting)); + return new MockAsyncPageable(copy); + }; + + var testClient = mockClient.Object; + + foreach (ConfigurationSetting setting in _invalidJsonKvCollection) + { + void action() => new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Select(setting.Key); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); + options.ConfigureKeyVault(kv => + { + kv.Register(mockSecretClient.Object); + }); + }) + .Build(); + + // Each of the secret references should throw an exception when parsed + Assert.Throws(action); + } + } + + [Fact] + public void AlternateValidKeyVaultSecretReferenceJsons() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + var cacheExpiration = TimeSpan.FromSeconds(1); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns((Func)GetTestKeys); + + var mockSecretClient = new Mock(MockBehavior.Strict); + mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net")); + mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string name, string version, CancellationToken cancellationToken) => + Task.FromResult((Response)new MockResponse(new KeyVaultSecret(name, _secretValue)))); + + MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) + { + var copy = new List(); + var newSetting = _validJsonKvCollection.FirstOrDefault(s => s.Key == selector.KeyFilter); + if (newSetting != null) + copy.Add(TestHelpers.CloneSetting(newSetting)); + return new MockAsyncPageable(copy); + }; + + var testClient = mockClient.Object; + + foreach (ConfigurationSetting setting in _validJsonKvCollection) + { + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Select(setting.Key); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); + options.ConfigureKeyVault(kv => + { + kv.Register(mockSecretClient.Object); + }); + }) + .Build(); + + // Each of the secret references should work as normal and use the uri + Assert.Equal(_secretValue, config[setting.Key]); + } + } } } 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 96aa5933..2c614acf 100644 --- a/tests/Tests.AzureAppConfiguration/LoggingTests.cs +++ b/tests/Tests.AzureAppConfiguration/LoggingTests.cs @@ -52,10 +52,10 @@ 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 void ValidateExceptionLoggedDuringRefresh() + public async Task ValidateExceptionLoggedDuringRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -81,7 +81,7 @@ public void ValidateExceptionLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -91,15 +91,15 @@ public void ValidateExceptionLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.NotEqual("newValue1", config["TestKey1"]); Assert.Contains(LoggingConstants.RefreshFailedError, warningInvocation); } [Fact] - public void ValidateUnauthorizedExceptionLoggedDuringRefresh() + public async Task ValidateUnauthorizedExceptionLoggedDuringRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -123,7 +123,7 @@ public void ValidateUnauthorizedExceptionLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -133,15 +133,15 @@ public void ValidateUnauthorizedExceptionLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.NotEqual("newValue1", config["TestKey1"]); Assert.Contains(LoggingConstants.RefreshFailedDueToAuthenticationError, warningInvocation); } [Fact] - public void ValidateInvalidOperationExceptionLoggedDuringRefresh() + public async Task ValidateInvalidOperationExceptionLoggedDuringRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -165,7 +165,7 @@ public void ValidateInvalidOperationExceptionLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -175,15 +175,15 @@ public void ValidateInvalidOperationExceptionLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.NotEqual("newValue1", config["TestKey1"]); Assert.Contains(LoggingConstants.RefreshFailedError, warningInvocation); } [Fact] - public void ValidateKeyVaultExceptionLoggedDuringRefresh() + public async Task ValidateKeyVaultExceptionLoggedDuringRefresh() { IConfigurationRefresher refresher = null; @@ -231,7 +231,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("SentinelKey", refreshAll: true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); }) @@ -241,14 +241,14 @@ Response GetIfChanged(ConfigurationSetting setting, bool o // Update sentinel key-value to trigger refreshAll operation sentinelKv.Value = "UpdatedSentinelValue"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Contains(LoggingConstants.RefreshFailedDueToKeyVaultError + "\nNo key vault credential or secret resolver callback configured, and no matching secret client could be found.", warningInvocation); } [Fact] - public void ValidateAggregateExceptionWithInnerOperationCanceledExceptionLoggedDuringRefresh() + public async Task ValidateAggregateExceptionWithInnerOperationCanceledExceptionLoggedDuringRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -272,7 +272,7 @@ public void ValidateAggregateExceptionWithInnerOperationCanceledExceptionLoggedD options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -282,15 +282,15 @@ public void ValidateAggregateExceptionWithInnerOperationCanceledExceptionLoggedD Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.NotEqual("newValue1", config["TestKey1"]); Assert.Contains(LoggingConstants.RefreshFailedError, warningInvocation); } [Fact] - public void ValidateOperationCanceledExceptionLoggedDuringRefresh() + public async Task ValidateOperationCanceledExceptionLoggedDuringRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -312,7 +312,7 @@ public void ValidateOperationCanceledExceptionLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -322,18 +322,18 @@ public void ValidateOperationCanceledExceptionLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); using var cancellationSource = new CancellationTokenSource(); cancellationSource.Cancel(); - refresher.TryRefreshAsync(cancellationSource.Token).Wait(); + await refresher.TryRefreshAsync(cancellationSource.Token); Assert.NotEqual("newValue1", config["TestKey1"]); Assert.Contains(LoggingConstants.RefreshCanceledError, warningInvocation); } [Fact] - public void ValidateFailoverToDifferentEndpointMessageLoggedAfterFailover() + public async Task ValidateFailoverToDifferentEndpointMessageLoggedAfterFailover() { IConfigurationRefresher refresher = null; var mockClient1 = GetMockConfigurationClient(); @@ -368,7 +368,7 @@ public void ValidateFailoverToDifferentEndpointMessageLoggedAfterFailover() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -380,8 +380,8 @@ public void ValidateFailoverToDifferentEndpointMessageLoggedAfterFailover() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("newValue1", config["TestKey1"]); Assert.Contains(LogHelper.BuildFailoverMessage(TestHelpers.PrimaryConfigStoreEndpoint.ToString(), TestHelpers.SecondaryConfigStoreEndpoint.ToString()), warningInvocation); @@ -392,15 +392,15 @@ public void ValidateFailoverToDifferentEndpointMessageLoggedAfterFailover() FirstKeyValue.Value = "TestValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("newValue1", config["TestKey1"]); Assert.Contains(LogHelper.BuildLastEndpointFailedMessage(TestHelpers.SecondaryConfigStoreEndpoint.ToString()), warningInvocation); } [Fact] - public void ValidateConfigurationUpdatedSuccessLoggedDuringRefresh() + public async Task ValidateConfigurationUpdatedSuccessLoggedDuringRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -424,7 +424,7 @@ public void ValidateConfigurationUpdatedSuccessLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -434,15 +434,15 @@ public void ValidateConfigurationUpdatedSuccessLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("newValue1", config["TestKey1"]); Assert.Contains(LogHelper.BuildConfigurationUpdatedMessage(), invocation); } [Fact] - public void ValidateCorrectEndpointLoggedOnConfigurationUpdate() + public async Task ValidateCorrectEndpointLoggedOnConfigurationUpdate() { IConfigurationRefresher refresher = null; var mockClient1 = new Mock(); @@ -474,7 +474,7 @@ public void ValidateCorrectEndpointLoggedOnConfigurationUpdate() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -483,15 +483,15 @@ public void ValidateCorrectEndpointLoggedOnConfigurationUpdate() FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); // We should see the second client's endpoint logged since the first client is backed off Assert.Contains(LogHelper.BuildKeyValueReadMessage(KeyValueChangeType.Modified, _kvCollection[0].Key, _kvCollection[0].Label, TestHelpers.SecondaryConfigStoreEndpoint.ToString().TrimEnd('/')), invocation); } [Fact] - public void ValidateCorrectKeyValueLoggedDuringRefresh() + public async Task ValidateCorrectKeyValueLoggedDuringRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -520,7 +520,7 @@ public void ValidateCorrectKeyValueLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", false).Register("TestKey2", false) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -530,8 +530,8 @@ public void ValidateCorrectKeyValueLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("newValue1", config["TestKey1"]); Assert.Contains(LogHelper.BuildKeyValueReadMessage(KeyValueChangeType.Modified, _kvCollection[0].Key, _kvCollection[0].Label, TestHelpers.PrimaryConfigStoreEndpoint.ToString().TrimEnd('/')), verboseInvocation); @@ -540,7 +540,7 @@ public void ValidateCorrectKeyValueLoggedDuringRefresh() } [Fact] - public void ValidateCorrectKeyVaultSecretLoggedDuringRefresh() + public async Task ValidateCorrectKeyVaultSecretLoggedDuringRefresh() { string _secretValue = "SecretValue from KeyVault"; Uri vaultUri = new Uri("https://keyvault-theclassics.vault.azure.net"); @@ -579,9 +579,9 @@ public void ValidateCorrectKeyVaultSecretLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); - options.ConfigureKeyVault(kv => kv.Register(mockSecretClient.Object).SetSecretRefreshInterval(CacheExpirationTime)); + options.ConfigureKeyVault(kv => kv.Register(mockSecretClient.Object).SetSecretRefreshInterval(RefreshInterval)); refresher = options.GetRefresher(); }) .Build(); @@ -593,8 +593,8 @@ public void ValidateCorrectKeyVaultSecretLoggedDuringRefresh() ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/Password3/6db5a48680104dda9097b1e6d859e553"" } "; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Contains(LogHelper.BuildKeyVaultSecretReadMessage(_kvr.Key, _kvr.Label), verboseInvocation); Assert.Contains(LogHelper.BuildKeyVaultSettingUpdatedMessage(_kvr.Key), informationalInvocation); } diff --git a/tests/Tests.AzureAppConfiguration/MapTests.cs b/tests/Tests.AzureAppConfiguration/MapTests.cs index 140282b7..95e13525 100644 --- a/tests/Tests.AzureAppConfiguration/MapTests.cs +++ b/tests/Tests.AzureAppConfiguration/MapTests.cs @@ -48,7 +48,7 @@ public class MapTests ConfigurationSetting FirstKeyValue => _kvCollection.First(); ConfigurationSetting sentinelKv = new ConfigurationSetting("SentinelKey", "SentinelValue"); - TimeSpan CacheExpirationTime = TimeSpan.FromSeconds(1); + TimeSpan RefreshInterval = TimeSpan.FromSeconds(1); string _certValue = "Certificate Value from KeyVault"; string _secretValue = "SecretValue from KeyVault"; @@ -130,7 +130,7 @@ public void MapTransformKeyVaultValueBeforeAdapters() } [Fact] - public void MapTransformWithRefresh() + public async Task MapTransformWithRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -144,7 +144,7 @@ public void MapTransformWithRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -175,15 +175,15 @@ public void MapTransformWithRefresh() FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("newValue1 mapped first", config["TestKey1"]); Assert.Equal("TestValue2 second", config["TestKey2"]); } [Fact] - public void MapTransformSettingKeyWithRefresh() + public async Task MapTransformSettingKeyWithRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -197,7 +197,7 @@ public void MapTransformSettingKeyWithRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -225,15 +225,15 @@ public void MapTransformSettingKeyWithRefresh() FirstKeyValue.Value = "newValue1"; _kvCollection.Last().Value = "newValue2"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("newValue1 changed", config["newTestKey1"]); Assert.Equal("newValue2", config["TestKey2"]); } [Fact] - public void MapTransformSettingLabelWithRefresh() + public async Task MapTransformSettingLabelWithRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -247,7 +247,7 @@ public void MapTransformSettingLabelWithRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -273,15 +273,15 @@ public void MapTransformSettingLabelWithRefresh() FirstKeyValue.Value = "newValue1"; _kvCollection.Last().Value = "newValue2"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("newValue1 changed", config["TestKey1"]); Assert.Equal("newValue2 changed", config["TestKey2"]); } [Fact] - public void MapTransformSettingCreateDuplicateKeyWithRefresh() + public async Task MapTransformSettingCreateDuplicateKeyWithRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -295,7 +295,7 @@ public void MapTransformSettingCreateDuplicateKeyWithRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -321,15 +321,15 @@ public void MapTransformSettingCreateDuplicateKeyWithRefresh() FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("TestValue2 changed", config["TestKey2"]); Assert.Null(config["TestKey1"]); } [Fact] - public void MapCreateNewSettingWithRefresh() + public async Task MapCreateNewSettingWithRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -343,7 +343,7 @@ public void MapCreateNewSettingWithRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -366,8 +366,8 @@ public void MapCreateNewSettingWithRefresh() Assert.Equal("TestValue2", config["TestKey2"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("mappedValue1", config["TestKey1"]); Assert.Equal("TestValue2", config["TestKey2"]); @@ -450,7 +450,7 @@ public void MapAsyncResolveKeyVaultReference() } [Fact] - public void MapTransformSettingKeyWithLogAndRefresh() + public async Task MapTransformSettingKeyWithLogAndRefresh() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -479,7 +479,7 @@ public void MapTransformSettingKeyWithLogAndRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -507,8 +507,8 @@ public void MapTransformSettingKeyWithLogAndRefresh() FirstKeyValue.Value = "newValue1"; _kvCollection.Last().Value = "newValue2"; - Thread.Sleep(CacheExpirationTime); - refresher.TryRefreshAsync().Wait(); + Thread.Sleep(RefreshInterval); + await refresher.TryRefreshAsync(); Assert.Equal("newValue1 changed", config["newTestKey1"]); Assert.Equal("newValue2", config["TestKey2"]); diff --git a/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs b/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs index aa88fc86..1b3291b2 100644 --- a/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs @@ -170,10 +170,59 @@ public class PushRefreshTests "Microsoft.AppConfiguration.KeyValueModified", "2", BinaryData.FromString("{\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"syncToken\":\"sn;CRAle3342\"}") ) + }, + + // Test that last syncToken is used + { + "sn;BYRte4456", + new EventGridEvent( + "https://store2.resource.io/kv/searchQuery2", + "Microsoft.AppConfiguration.KeyValueModified", "2", + BinaryData.FromString("{\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"syncToken\":\"sn;CRAle3342\",\"syncToken\":\"sn;BYRte4456\"}") + ) } }; - ConfigurationSetting FirstKeyValue => _kvCollection.First(); + Dictionary _invalidFormatEventGridEvents = new Dictionary + { + { + "sn;Vxujfidne", + new EventGridEvent( + "https://store1.resource.io/kv/searchQuery1", + "Microsoft.AppConfiguration.KeyValueModified", "2", + BinaryData.FromString("\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"syncToken\":\"sn;Vxujfidne\"}") + ) + }, + + { + "sn;AxRty78B", + new EventGridEvent( + "https://store1.resource.io/kv/searchQuery1", + "Microsoft.AppConfiguration.KeyValueModified", "2", + BinaryData.FromString("{\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"syncToken\":\"sn;Vxujfidne\"") + ) + }, + + { + "sn;Ttylmable", + new EventGridEvent( + "https://store1.resource.io/kv/searchQuery2", + "Microsoft.AppConfiguration.KeyValueDeleted", "2", + BinaryData.FromString("{\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"fake_property\":{\"syncToken\":\"sn;Ttylmable\"}}") + ) + }, + + { + "sn;CRAle3342", + new EventGridEvent( + "https://store2.resource.io/kv/searchQuery2", + "Microsoft.AppConfiguration.KeyValueModified", "2", + BinaryData.FromString("{\"fake_property\":{\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"syncToken\":\"sn;CRAle3342\"}}") + ) + } + }; + + ConfigurationSetting FirstKeyValue => _kvCollection.First(); [Fact] public void ValidatePushNotificationCreation() @@ -191,6 +240,17 @@ public void ValidatePushNotificationCreation() } } + [Fact] + public void InvalidPushNotificationCreation() + { + foreach (KeyValuePair eventGridAndSync in _invalidFormatEventGridEvents) + { + EventGridEvent eventGridEvent = eventGridAndSync.Value; + + Assert.False(eventGridEvent.TryCreatePushNotification(out PushNotification _)); + } + } + [Fact] public void ProcessPushNotificationThrowsArgumentExceptions() { @@ -207,7 +267,7 @@ public void ProcessPushNotificationThrowsArgumentExceptions() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromDays(30)); + .SetRefreshInterval(TimeSpan.FromDays(30)); }); refresher = options.GetRefresher(); }) @@ -226,7 +286,7 @@ public void ProcessPushNotificationThrowsArgumentExceptions() } [Fact] - public void SyncTokenUpdatesCorrectNumberOfTimes() + public async Task SyncTokenUpdatesCorrectNumberOfTimes() { // Arrange var mockResponse = new Mock(); @@ -243,7 +303,7 @@ public void SyncTokenUpdatesCorrectNumberOfTimes() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromDays(30)); + .SetRefreshInterval(TimeSpan.FromDays(30)); }); refresher = options.GetRefresher(); }) @@ -252,7 +312,7 @@ public void SyncTokenUpdatesCorrectNumberOfTimes() foreach (PushNotification pushNotification in _pushNotificationList) { refresher.ProcessPushNotification(pushNotification, TimeSpan.FromSeconds(0)); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); } var validNotificationKVWatcherCount = 8; @@ -264,7 +324,7 @@ public void SyncTokenUpdatesCorrectNumberOfTimes() } [Fact] - public void RefreshAsyncUpdatesConfig() + public async Task RefreshAsyncUpdatesConfig() { // Arrange var mockResponse = new Mock(); @@ -280,7 +340,7 @@ public void RefreshAsyncUpdatesConfig() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromDays(30)); + .SetRefreshInterval(TimeSpan.FromDays(30)); }); refresher = options.GetRefresher(); }) @@ -291,7 +351,7 @@ public void RefreshAsyncUpdatesConfig() FirstKeyValue.Value = "newValue1"; refresher.ProcessPushNotification(_pushNotificationList.First(), TimeSpan.FromSeconds(0)); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); Assert.Equal("newValue1", config["TestKey1"]); } diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index 48becbd9..1faae290 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -97,7 +97,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refresh => { refresh.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(60)); + .SetRefreshInterval(TimeSpan.FromSeconds(60)); }); }) .Build(); @@ -119,7 +119,7 @@ public void RefreshTests_RefreshRegisteredKeysAreLoadedOnStartup_CustomUseQuery( { refreshOptions.Register("TestKey2", "label") .Register("TestKey3", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(60)); + .SetRefreshInterval(TimeSpan.FromSeconds(60)); }); }) .Build(); @@ -130,7 +130,7 @@ public void RefreshTests_RefreshRegisteredKeysAreLoadedOnStartup_CustomUseQuery( } [Fact] - public void RefreshTests_RefreshIsSkippedIfCacheIsNotExpired() + public async Task RefreshTests_RefreshIsSkippedIfCacheIsNotExpired() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -142,7 +142,7 @@ public void RefreshTests_RefreshIsSkippedIfCacheIsNotExpired() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(10)); + .SetRefreshInterval(TimeSpan.FromSeconds(10)); }); refresher = options.GetRefresher(); @@ -152,13 +152,13 @@ public void RefreshTests_RefreshIsSkippedIfCacheIsNotExpired() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); Assert.Equal("TestValue1", config["TestKey1"]); } [Fact] - public void RefreshTests_RefreshIsSkippedIfKvNotInSelectAndCacheIsNotExpired() + public async Task RefreshTests_RefreshIsSkippedIfKvNotInSelectAndCacheIsNotExpired() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClientSelectKeyLabel(); @@ -171,7 +171,7 @@ public void RefreshTests_RefreshIsSkippedIfKvNotInSelectAndCacheIsNotExpired() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(10)); + .SetRefreshInterval(TimeSpan.FromSeconds(10)); }); refresher = options.GetRefresher(); @@ -181,13 +181,13 @@ public void RefreshTests_RefreshIsSkippedIfKvNotInSelectAndCacheIsNotExpired() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); Assert.Equal("TestValue1", config["TestKey1"]); } [Fact] - public void RefreshTests_RefreshIsNotSkippedIfCacheIsExpired() + public async Task RefreshTests_RefreshIsNotSkippedIfCacheIsExpired() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -200,7 +200,7 @@ public void RefreshTests_RefreshIsNotSkippedIfCacheIsExpired() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -213,13 +213,13 @@ public void RefreshTests_RefreshIsNotSkippedIfCacheIsExpired() // Wait for the cache to expire Thread.Sleep(1500); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); Assert.Equal("newValue", config["TestKey1"]); } [Fact] - public void RefreshTests_RefreshAllFalseDoesNotUpdateEntireConfiguration() + public async Task RefreshTests_RefreshAllFalseDoesNotUpdateEntireConfiguration() { var keyValueCollection = new List(_kvCollection); IConfigurationRefresher refresher = null; @@ -233,7 +233,7 @@ public void RefreshTests_RefreshAllFalseDoesNotUpdateEntireConfiguration() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") // refreshAll: false - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -249,7 +249,7 @@ public void RefreshTests_RefreshAllFalseDoesNotUpdateEntireConfiguration() // Wait for the cache to expire Thread.Sleep(1500); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); Assert.Equal("newValue", config["TestKey1"]); Assert.NotEqual("newValue", config["TestKey2"]); @@ -257,7 +257,7 @@ public void RefreshTests_RefreshAllFalseDoesNotUpdateEntireConfiguration() } [Fact] - public void RefreshTests_RefreshAllTrueUpdatesEntireConfiguration() + public async Task RefreshTests_RefreshAllTrueUpdatesEntireConfiguration() { var keyValueCollection = new List(_kvCollection); IConfigurationRefresher refresher = null; @@ -271,7 +271,7 @@ public void RefreshTests_RefreshAllTrueUpdatesEntireConfiguration() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", refreshAll: true) - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -287,7 +287,7 @@ public void RefreshTests_RefreshAllTrueUpdatesEntireConfiguration() // Wait for the cache to expire Thread.Sleep(1500); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); Assert.Equal("newValue", config["TestKey1"]); Assert.Equal("newValue", config["TestKey2"]); @@ -295,7 +295,7 @@ public void RefreshTests_RefreshAllTrueUpdatesEntireConfiguration() } [Fact] - public void RefreshTests_RefreshAllTrueRemovesDeletedConfiguration() + public async Task RefreshTests_RefreshAllTrueRemovesDeletedConfiguration() { var keyValueCollection = new List(_kvCollection); var mockResponse = new Mock(); @@ -342,7 +342,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", refreshAll: true) - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -359,7 +359,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o // Wait for the cache to expire Thread.Sleep(1500); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); Assert.Equal("newValue", config["TestKey1"]); Assert.Equal("TestValue2", config["TestKey2"]); @@ -367,7 +367,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o } [Fact] - public void RefreshTests_RefreshAllForNonExistentSentinelDoesNothing() + public async Task RefreshTests_RefreshAllForNonExistentSentinelDoesNothing() { var keyValueCollection = new List(_kvCollection); var mockResponse = new Mock(); @@ -415,7 +415,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o { refreshOptions.Register("TestKey1", "label") .Register("NonExistentKey", refreshAll: true) - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -433,7 +433,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o // Wait for the cache to expire Thread.Sleep(1500); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); // Validate that key-values registered for refresh were updated Assert.Equal("newValue1", config["TestKey1"]); @@ -444,7 +444,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o } [Fact] - public void RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() + public async void RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() { var keyValueCollection = new List(_kvCollection); var requestCount = 0; @@ -489,7 +489,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -505,7 +505,8 @@ Response GetIfChanged(ConfigurationSetting setting, bool o var task1 = Task.Run(() => WaitAndRefresh(refresher, 1500)); var task2 = Task.Run(() => WaitAndRefresh(refresher, 3000)); var task3 = Task.Run(() => WaitAndRefresh(refresher, 4500)); - Task.WaitAll(task1, task2, task3); + + await Task.WhenAll(task1, task2, task3); Assert.Equal("newValue", config["TestKey1"]); Assert.Equal(2, requestCount); @@ -525,7 +526,7 @@ public void RefreshTests_RefreshAsyncThrowsOnRequestFailedException() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -548,7 +549,7 @@ public void RefreshTests_RefreshAsyncThrowsOnRequestFailedException() } [Fact] - public void RefreshTests_TryRefreshAsyncReturnsFalseOnRequestFailedException() + public async Task RefreshTests_TryRefreshAsyncReturnsFalseOnRequestFailedException() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -561,7 +562,7 @@ public void RefreshTests_TryRefreshAsyncReturnsFalseOnRequestFailedException() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -577,14 +578,14 @@ public void RefreshTests_TryRefreshAsyncReturnsFalseOnRequestFailedException() // Wait for the cache to expire Thread.Sleep(1500); - bool result = refresher.TryRefreshAsync().Result; + bool result = await refresher.TryRefreshAsync(); Assert.False(result); Assert.NotEqual("newValue", config["TestKey1"]); } [Fact] - public void RefreshTests_TryRefreshAsyncUpdatesConfigurationAndReturnsTrueOnSuccess() + public async Task RefreshTests_TryRefreshAsyncUpdatesConfigurationAndReturnsTrueOnSuccess() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -597,7 +598,7 @@ public void RefreshTests_TryRefreshAsyncUpdatesConfigurationAndReturnsTrueOnSucc options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -610,14 +611,14 @@ public void RefreshTests_TryRefreshAsyncUpdatesConfigurationAndReturnsTrueOnSucc // Wait for the cache to expire Thread.Sleep(1500); - bool result = refresher.TryRefreshAsync().Result; + bool result = await refresher.TryRefreshAsync(); Assert.True(result); Assert.Equal("newValue", config["TestKey1"]); } [Fact] - public void RefreshTests_TryRefreshAsyncReturnsFalseForAuthenticationFailedException() + public async Task RefreshTests_TryRefreshAsyncReturnsFalseForAuthenticationFailedException() { IConfigurationRefresher refresher = null; var mockResponse = new Mock(); @@ -640,7 +641,7 @@ public void RefreshTests_TryRefreshAsyncReturnsFalseForAuthenticationFailedExcep options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -654,13 +655,13 @@ public void RefreshTests_TryRefreshAsyncReturnsFalseForAuthenticationFailedExcep Thread.Sleep(1500); // First call to GetConfigurationSettingAsync does not throw - Assert.True(refresher.TryRefreshAsync().Result); + Assert.True(await refresher.TryRefreshAsync()); // Wait for the cache to expire Thread.Sleep(1500); // Second call to GetConfigurationSettingAsync throws KeyVaultReferenceException - Assert.False(refresher.TryRefreshAsync().Result); + Assert.False(await refresher.TryRefreshAsync()); } [Fact] @@ -693,7 +694,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", refreshAll: true) - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -735,7 +736,7 @@ public async Task RefreshTests_UpdatesAllSettingsIfInitialLoadFails() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -774,7 +775,7 @@ await Assert.ThrowsAsync(async () => } [Fact] - public void RefreshTests_SentinelKeyNotUpdatedOnRefreshAllFailure() + public async Task RefreshTests_SentinelKeyNotUpdatedOnRefreshAllFailure() { var keyValueCollection = new List(_kvCollection); var mockResponse = new Mock(); @@ -811,7 +812,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", refreshAll: true) - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -827,7 +828,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o // Wait for the cache to expire Thread.Sleep(1500); - bool firstRefreshResult = refresher.TryRefreshAsync().Result; + bool firstRefreshResult = await refresher.TryRefreshAsync(); Assert.False(firstRefreshResult); Assert.Equal("TestValue1", config["TestKey1"]); @@ -837,7 +838,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o // Wait for the cache to expire Thread.Sleep(1500); - bool secondRefreshResult = refresher.TryRefreshAsync().Result; + bool secondRefreshResult = await refresher.TryRefreshAsync(); Assert.True(secondRefreshResult); Assert.Equal("newValue", config["TestKey1"]); @@ -846,7 +847,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o } [Fact] - public void RefreshTests_RefreshAllTrueForOverwrittenSentinelUpdatesEntireConfiguration() + public async Task RefreshTests_RefreshAllTrueForOverwrittenSentinelUpdatesEntireConfiguration() { var keyValueCollection = new List(_kvCollection); IConfigurationRefresher refresher = null; @@ -861,7 +862,7 @@ public void RefreshTests_RefreshAllTrueForOverwrittenSentinelUpdatesEntireConfig { refreshOptions.Register("TestKeyWithMultipleLabels", "label1", refreshAll: true) .Register("TestKeyWithMultipleLabels", "label2") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -878,7 +879,7 @@ public void RefreshTests_RefreshAllTrueForOverwrittenSentinelUpdatesEntireConfig // Wait for the cache to expire Thread.Sleep(1500); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); Assert.Equal("newValue", config["TestKey1"]); Assert.Equal("newValue", config["TestKey2"]); @@ -887,7 +888,7 @@ public void RefreshTests_RefreshAllTrueForOverwrittenSentinelUpdatesEntireConfig } [Fact] - public void RefreshTests_RefreshAllFalseForOverwrittenSentinelUpdatesConfig() + public async Task RefreshTests_RefreshAllFalseForOverwrittenSentinelUpdatesConfig() { var keyValueCollection = new List(_kvCollection); ConfigurationSetting refreshRegisteredSetting = keyValueCollection.FirstOrDefault(s => s.Key == "TestKeyWithMultipleLabels" && s.Label == "label1"); @@ -903,7 +904,7 @@ public void RefreshTests_RefreshAllFalseForOverwrittenSentinelUpdatesConfig() { refreshOptions.Register("TestKeyWithMultipleLabels", "label1") .Register("TestKeyWithMultipleLabels", "label2") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -920,7 +921,7 @@ public void RefreshTests_RefreshAllFalseForOverwrittenSentinelUpdatesConfig() // Wait for the cache to expire Thread.Sleep(1500); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); // Validate that refresh registered key-value was updated Assert.Equal("TestValue1", config["TestKey1"]); @@ -930,7 +931,7 @@ public void RefreshTests_RefreshAllFalseForOverwrittenSentinelUpdatesConfig() } [Fact] - public void RefreshTests_RefreshRegisteredKvOverwritesSelectedKv() + public async Task RefreshTests_RefreshRegisteredKvOverwritesSelectedKv() { var keyValueCollection = new List(_kvCollection); ConfigurationSetting refreshAllRegisteredSetting = keyValueCollection.FirstOrDefault(s => s.Key == "TestKeyWithMultipleLabels" && s.Label == "label1"); @@ -945,7 +946,7 @@ public void RefreshTests_RefreshRegisteredKvOverwritesSelectedKv() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKeyWithMultipleLabels", "label1") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -963,7 +964,7 @@ public void RefreshTests_RefreshRegisteredKvOverwritesSelectedKv() // Wait for the cache to expire Thread.Sleep(1500); - refresher.RefreshAsync().Wait(); + await refresher.RefreshAsync(); // Validate that only the refresh registered key-value was updated Assert.Equal("TestValue1", config["TestKey1"]); @@ -1013,7 +1014,7 @@ public void RefreshTests_ConfigureRefreshThrowsOnNoRegistration() { options.ConfigureRefresh(refreshOptions => { - refreshOptions.SetCacheExpiration(TimeSpan.FromSeconds(1)); + refreshOptions.SetRefreshInterval(TimeSpan.FromSeconds(1)); }); }) .Build(); @@ -1034,7 +1035,7 @@ public void RefreshTests_RefreshIsCancelled() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -1055,7 +1056,7 @@ public void RefreshTests_RefreshIsCancelled() Assert.Equal("TestValue1", config["TestKey1"]); } -#if NET7_0 +#if NET8_0 [Fact] public void RefreshTests_ChainedConfigurationProviderUsedAsRootForRefresherProvider() { diff --git a/tests/Tests.AzureAppConfiguration/TestHelper.cs b/tests/Tests.AzureAppConfiguration/TestHelper.cs index fe1685d9..477b4429 100644 --- a/tests/Tests.AzureAppConfiguration/TestHelper.cs +++ b/tests/Tests.AzureAppConfiguration/TestHelper.cs @@ -115,16 +115,22 @@ public static ConfigurationSetting CloneSetting(ConfigurationSetting setting) public static List LoadJsonSettingsFromFile(string path) { List _kvCollection = new List(); - var valueArray = JsonSerializer.Deserialize(File.ReadAllText(path)).EnumerateArray(); - foreach (var setting in valueArray) + + using (JsonDocument document = JsonDocument.Parse(File.ReadAllText(path))) { - ConfigurationSetting kv = ConfigurationModelFactory - .ConfigurationSetting( - key: setting.GetProperty("key").ToString(), - value: setting.GetProperty("value").GetRawText(), - contentType: setting.GetProperty("contentType").ToString()); - _kvCollection.Add(kv); + var valueArray = document.RootElement.EnumerateArray(); + + foreach (var setting in valueArray) + { + ConfigurationSetting kv = ConfigurationModelFactory + .ConfigurationSetting( + key: setting.GetProperty("key").ToString(), + value: setting.GetProperty("value").GetRawText(), + contentType: setting.GetProperty("contentType").ToString()); + _kvCollection.Add(kv); + } } + return _kvCollection; } diff --git a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj index 0dfcabbd..e628ecca 100644 --- a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj +++ b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj @@ -1,33 +1,33 @@  - net48;net6.0;net7.0 + net48;net6.0;net8.0 8.0 false true ..\..\build\AzureAppConfiguration.snk false + false - - - - + + + - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + - - - - Always diff --git a/tests/Tests.AzureAppConfiguration/Tests.cs b/tests/Tests.AzureAppConfiguration/Tests.cs index b7e61978..c3b27124 100644 --- a/tests/Tests.AzureAppConfiguration/Tests.cs +++ b/tests/Tests.AzureAppConfiguration/Tests.cs @@ -239,7 +239,7 @@ public void TestUserAgentHeader() // 4. Contains the name and version of the App Configuration SDK package // 5. Contains the runtime information (target framework, OS description etc.) in the format set by the SDK // 6. Does not contain any additional components - string userAgentRegex = @"^Microsoft\.Extensions\.Configuration\.AzureAppConfiguration/\d+\.\d+\.\d+(-preview(\.\d+)?)?,azsdk-net-Data.AppConfiguration/[.+\w-]+ \([.;\w\s]+\)$"; + string userAgentRegex = @"^Microsoft\.Extensions\.Configuration\.AzureAppConfiguration/\d+\.\d+\.\d+(\+[a-z0-9]+)?(-preview(\.\d+)?)?,azsdk-net-Data.AppConfiguration/[.+\w-]+ \([.;\w\s]+\)$"; var response = new MockResponse(200); response.SetContent(SerializationHelpers.Serialize(_kvCollectionPageOne.ToArray(), TestHelpers.SerializeBatch));