Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support prefix filtering and prefix trimming from feature flags #234

Merged
merged 6 commits into from
Mar 26, 2021
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Azure.Core;
using Azure.Data.AppConfiguration;
using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault;
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions;
using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement;
avanigupta marked this conversation as resolved.
Show resolved Hide resolved
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models;
using System;
Expand Down Expand Up @@ -33,7 +34,10 @@ public class AzureAppConfigurationOptions
private List<KeyValueSelector> _kvSelectors = new List<KeyValueSelector>();
private IConfigurationRefresher _refresher = new AzureAppConfigurationRefresher();

private SortedSet<string> _keyPrefixes = new SortedSet<string>(Comparer<string>.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.InvariantCultureIgnoreCase)));
// The following sets are sorted in descending order.
// Since multiple prefixes could start with the same characters, we need to trim the longest prefix first.
private SortedSet<string> _keyPrefixes = new SortedSet<string>(Comparer<string>.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase)));
private SortedSet<string> _featureFlagPrefixes = new SortedSet<string>(Comparer<string>.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase)));

/// <summary>
/// The connection string to use to connect to Azure App Configuration.
Expand Down Expand Up @@ -154,24 +158,54 @@ public AzureAppConfigurationOptions UseFeatureFlags(Action<FeatureFlagOptions> c
string.Format(ErrorMessages.CacheExpirationTimeTooShort, MinimumFeatureFlagsCacheExpirationInterval.TotalMilliseconds));
}

if (!(_kvSelectors.Any(selector => selector.KeyFilter.StartsWith(FeatureManagementConstants.FeatureFlagMarker) && selector.LabelFilter.Equals(options.Label))))
if (options.FeatureFlagSelectors.Count() != 0 && options.Label != null)
{
avanigupta marked this conversation as resolved.
Show resolved Hide resolved
Select(FeatureManagementConstants.FeatureFlagMarker + "*", options.Label);
throw new InvalidOperationException($"Please select feature flags by either the {nameof(options.Select)} method or by setting the {nameof(options.Label)} property, not both.");
}

if (!_adapters.Any(a => a is FeatureManagementKeyValueAdapter))
if (options.FeatureFlagSelectors.Count() == 0)
{
_adapters.Add(new FeatureManagementKeyValueAdapter());
// Select clause is not present
options.FeatureFlagSelectors.Add(new KeyValueSelector
{
KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*",
LabelFilter = options.Label == null ? LabelFilter.Null : options.Label
});
}

avanigupta marked this conversation as resolved.
Show resolved Hide resolved
if (!_multiKeyWatchers.Any(kw => kw.Key.Equals(FeatureManagementConstants.FeatureFlagMarker)))
foreach (var featureFlagSelector in options.FeatureFlagSelectors)
{
_multiKeyWatchers.Add(new KeyValueWatcher
var featureFlagFilter = featureFlagSelector.KeyFilter;
var labelFilter = featureFlagSelector.LabelFilter;

if (!_kvSelectors.Any(selector => selector.KeyFilter == featureFlagFilter && selector.LabelFilter == labelFilter))
{
Key = FeatureManagementConstants.FeatureFlagMarker,
Label = options.Label,
CacheExpirationInterval = options.CacheExpirationInterval
});
Select(featureFlagFilter, labelFilter);
}

var multiKeyWatcher = _multiKeyWatchers.FirstOrDefault(kw => kw.Key.Equals(featureFlagFilter) && kw.Label.NormalizeNull() == labelFilter.NormalizeNull());

avanigupta marked this conversation as resolved.
Show resolved Hide resolved
if (multiKeyWatcher == null)
{
_multiKeyWatchers.Add(new KeyValueWatcher
{
Key = featureFlagFilter,
Label = labelFilter,
CacheExpirationInterval = options.CacheExpirationInterval
});
}
else
{
// If UseFeatureFlags is called multiple times for the same key and label filters, last cache expiration time wins
multiKeyWatcher.CacheExpirationInterval = options.CacheExpirationInterval;
}
}

options.FeatureFlagPrefixes.ForEach(prefix => _featureFlagPrefixes.Add(prefix));

if (!_adapters.Any(a => a is FeatureManagementKeyValueAdapter))
{
_adapters.Add(new FeatureManagementKeyValueAdapter(_featureFlagPrefixes));
}

return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,14 +462,28 @@ private async Task RefreshKeyValueCollections()

try
{
IEnumerable<ConfigurationSetting> currentKeyValues = _applicationSettings.Values.Where(kv =>
IEnumerable<ConfigurationSetting> currentKeyValues;

if (changeWatcher.Key.EndsWith("*"))
{
return kv.Key.StartsWith(changeWatcher.Key) && kv.Label == changeWatcher.Label.NormalizeNull();
});
// Get current application settings starting with changeWatcher.Key, excluding the last * character
var keyPrefix = changeWatcher.Key.Substring(0, changeWatcher.Key.Length - 1);
currentKeyValues = _applicationSettings.Values.Where(kv =>
{
return kv.Key.StartsWith(keyPrefix) && kv.Label == changeWatcher.Label.NormalizeNull();
});
}
avanigupta marked this conversation as resolved.
Show resolved Hide resolved
else
{
currentKeyValues = _applicationSettings.Values.Where(kv =>
{
return kv.Key.Equals(changeWatcher.Key) && kv.Label == changeWatcher.Label.NormalizeNull();
});
}

IEnumerable<KeyValueChange> keyValueChanges = await _client.GetKeyValueChangeCollection(currentKeyValues, new GetKeyValueChangeCollectionOptions
{
Prefix = changeWatcher.Key,
KeyFilter = changeWatcher.Key,
Label = changeWatcher.Label.NormalizeNull(),
RequestTracingEnabled = _requestTracingEnabled,
HostType = _hostType
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,26 +72,16 @@ public static async Task<IEnumerable<KeyValueChange>> GetKeyValueChangeCollectio
keyValues = Enumerable.Empty<ConfigurationSetting>();
}

if (options.Prefix == null)
if (options.KeyFilter == null)
{
options.Prefix = string.Empty;
}

if (options.Prefix.Contains('*'))
{
throw new ArgumentException("The prefix cannot contain '*'", $"{nameof(options)}.{nameof(options.Prefix)}");
options.KeyFilter = string.Empty;
}

if (keyValues.Any(k => string.IsNullOrEmpty(k.Key)))
{
throw new ArgumentNullException($"{nameof(keyValues)}[].{nameof(ConfigurationSetting.Key)}");
}

if (!string.IsNullOrEmpty(options.Prefix) && keyValues.Any(k => !k.Key.StartsWith(options.Prefix)))
{
throw new ArgumentException("All key-values registered for refresh must start with the provided prefix.", $"{nameof(keyValues)}[].{nameof(ConfigurationSetting.Key)}");
}

if (keyValues.Any(k => !string.Equals(k.Label.NormalizeNull(), options.Label.NormalizeNull())))
{
throw new ArgumentException("All key-values registered for refresh must use the same label.", $"{nameof(keyValues)}[].{nameof(ConfigurationSetting.Label)}");
Expand All @@ -105,7 +95,7 @@ public static async Task<IEnumerable<KeyValueChange>> GetKeyValueChangeCollectio
var hasKeyValueCollectionChanged = false;
var selector = new SettingSelector
{
KeyFilter = options.Prefix + "*",
KeyFilter = options.KeyFilter,
LabelFilter = string.IsNullOrEmpty(options.Label) ? LabelFilter.Null : options.Label,
Fields = SettingFields.ETag | SettingFields.Key
};
Expand Down Expand Up @@ -142,7 +132,7 @@ await TracingUtils.CallWithRequestTracing(options.RequestTracingEnabled, Request
{
selector = new SettingSelector
{
KeyFilter = options.Prefix + "*",
KeyFilter = options.KeyFilter,
LabelFilter = string.IsNullOrEmpty(options.Label) ? LabelFilter.Null : options.Label
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement
{
Expand All @@ -10,14 +13,93 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManage
/// </summary>
public class FeatureFlagOptions
{
/// <summary>
/// A collection of key prefixes to be trimmed.
/// </summary>
internal List<string> FeatureFlagPrefixes = new List<string>();

/// <summary>
/// A collection of <see cref="KeyValueSelector"/>.
/// </summary>
internal List<KeyValueSelector> FeatureFlagSelectors = new List<KeyValueSelector>();

/// <summary>
/// The label that feature flags will be selected from.
/// </summary>
public string Label { get; set; } = LabelFilter.Null;
public string Label { get; set; }

/// <summary>
/// The time after which the cached values of the feature flags expire. Must be greater than or equal to 1 second.
/// </summary>
public TimeSpan CacheExpirationInterval { get; set; } = AzureAppConfigurationOptions.DefaultFeatureFlagsCacheExpirationInterval;

/// <summary>
/// Specify what feature flags to include in the configuration provider.
/// <see cref="Select"/> can be called multiple times to include multiple sets of feature flags.
/// </summary>
/// <param name="featureFlagFilter">
/// The filter to apply to feature flag names when querying Azure App Configuration for feature flags.
/// For example, you can select all feature flags that begin with "MyApp" by setting the featureflagFilter to "MyApp*".
/// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\).
avanigupta marked this conversation as resolved.
Show resolved Hide resolved
/// Built-in feature flag filter options: <see cref="KeyFilter"/>.
/// </param>
/// <param name="labelFilter">
/// The label filter to apply when querying Azure App Configuration for feature flags. By default the null label will be used. Built-in label filter options: <see cref="LabelFilter"/>
/// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\).
/// </param>
public FeatureFlagOptions Select(string featureFlagFilter, string labelFilter = LabelFilter.Null)
{
if (string.IsNullOrEmpty(featureFlagFilter))
{
throw new ArgumentNullException(nameof(featureFlagFilter));
}

if (featureFlagFilter.EndsWith(@"\*"))
{
throw new ArgumentException(@"Feature flag filter should not end with '\*'.", nameof(featureFlagFilter));
}

if (labelFilter == null)
{
labelFilter = LabelFilter.Null;
}

// Do not support * and , for label filter for now.
if (labelFilter.Contains('*') || labelFilter.Contains(','))
{
throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter));
}

string featureFlagPrefix = FeatureManagementConstants.FeatureFlagMarker + featureFlagFilter;

if (!FeatureFlagSelectors.Any(s => s.KeyFilter.Equals(featureFlagPrefix) && s.LabelFilter.Equals(labelFilter)))
{
FeatureFlagSelectors.Add(new KeyValueSelector
{
KeyFilter = featureFlagPrefix,
LabelFilter = labelFilter
});
}

return this;
}

/// <summary>
/// Trims the provided prefix from the keys of all feature flags retrieved from Azure App Configuration.
/// </summary>
/// <remarks>
/// For example, you can trim the prefix "MyApp" from all feature flags by setting the prefix to "MyApp".
/// </remarks>
/// <param name="prefix">The prefix to be trimmed.</param>
public FeatureFlagOptions TrimFeatureFlagPrefix(string prefix)
{
if (string.IsNullOrEmpty(prefix))
{
throw new ArgumentNullException(nameof(prefix));
}

FeatureFlagPrefixes.Add(prefix);
return this;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManage
{
internal class FeatureManagementKeyValueAdapter : IKeyValueAdapter
{
private readonly IEnumerable<string> _featureFlagPrefixes;

public FeatureManagementKeyValueAdapter(IEnumerable<string> featureFlagPrefixes)
{
_featureFlagPrefixes = featureFlagPrefixes ?? throw new ArgumentNullException(nameof(featureFlagPrefixes));
}

public Task<IEnumerable<KeyValuePair<string, string>>> ProcessKeyValue(ConfigurationSetting setting, CancellationToken cancellationToken)
{
FeatureFlag featureFlag;
Expand All @@ -25,8 +32,17 @@ public Task<IEnumerable<KeyValuePair<string, string>>> ProcessKeyValue(Configura
throw new FormatException(setting.Key, e);
}

foreach (string prefix in _featureFlagPrefixes)
{
if (featureFlag.Id.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
featureFlag.Id = featureFlag.Id.Substring(prefix.Length);
avanigupta marked this conversation as resolved.
Show resolved Hide resolved
break;
}
}

var keyValues = new List<KeyValuePair<string, string>>();

if (featureFlag.Enabled)
{
//if (featureFlag.Conditions?.ClientFilters == null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
{
internal class GetKeyValueChangeCollectionOptions
{
public string Prefix { get; set; }
public string KeyFilter { get; set; }
public string Label { get; set; }
public bool RequestTracingEnabled { get; set; }
public HostType HostType { get; set; }
Expand Down
Loading