diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index 141f595e..6e940760 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -22,13 +22,13 @@ public sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionP // IFeatureDefinitionProviderCacheable interface is only used to mark this provider as cacheable. This allows our test suite's // provider to be marked for caching as well. - private const string FeatureFiltersSectionName = "EnabledFor"; - private const string RequirementTypeKeyword = "RequirementType"; - private const string FeatureManagementSectionName = "FeatureManagement"; private readonly IConfiguration _configuration; private readonly ConcurrentDictionary _definitions; private IDisposable _changeSubscription; private int _stale = 0; + private long _initialized = 0; + private bool _microsoftFeatureFlagSchemaEnabled; + private readonly object _lock = new object(); /// /// Creates a configuration feature definition provider. @@ -81,6 +81,8 @@ public Task GetFeatureDefinitionAsync(string featureName) throw new ArgumentException($"The value '{ConfigurationPath.KeyDelimiter}' is not allowed in the feature name.", nameof(featureName)); } + EnsureInit(); + if (Interlocked.Exchange(ref _stale, 0) != 0) { _definitions.Clear(); @@ -104,6 +106,8 @@ public Task GetFeatureDefinitionAsync(string featureName) public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() #pragma warning restore CS1998 { + EnsureInit(); + if (Interlocked.Exchange(ref _stale, 0) != 0) { _definitions.Clear(); @@ -113,16 +117,55 @@ public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() // Iterate over all features registered in the system at initial invocation time foreach (IConfigurationSection featureSection in GetFeatureDefinitionSections()) { + string featureName = GetFeatureName(featureSection); + + if (string.IsNullOrEmpty(featureName)) + { + continue; + } + // // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned - yield return _definitions.GetOrAdd(featureSection.Key, (_) => ReadFeatureDefinition(featureSection)); + yield return _definitions.GetOrAdd(featureName, (_) => ReadFeatureDefinition(featureSection)); + } + } + + private void EnsureInit() + { + if (_initialized == 0) + { + IConfiguration featureManagementConfigurationSection = _configuration + .GetChildren() + .FirstOrDefault(section => + string.Equals( + section.Key, + ConfigurationFields.FeatureManagementSectionName, + StringComparison.OrdinalIgnoreCase)); + + if (featureManagementConfigurationSection == null && RootConfigurationFallbackEnabled) + { + featureManagementConfigurationSection = _configuration; + } + + bool hasMicrosoftFeatureFlagSchema = featureManagementConfigurationSection != null && + HasMicrosoftFeatureFlagSchema(featureManagementConfigurationSection); + + lock (_lock) + { + if (Interlocked.Read(ref _initialized) == 0) + { + _microsoftFeatureFlagSchemaEnabled = hasMicrosoftFeatureFlagSchema; + + Interlocked.Exchange(ref _initialized, 1); + } + } } } private FeatureDefinition ReadFeatureDefinition(string featureName) { IConfigurationSection configuration = GetFeatureDefinitionSections() - .FirstOrDefault(section => section.Key.Equals(featureName, StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(section => string.Equals(GetFeatureName(section), featureName, StringComparison.OrdinalIgnoreCase)); if (configuration == null) { @@ -133,6 +176,16 @@ private FeatureDefinition ReadFeatureDefinition(string featureName) } private FeatureDefinition ReadFeatureDefinition(IConfigurationSection configurationSection) + { + if (_microsoftFeatureFlagSchemaEnabled) + { + return ParseMicrosoftFeatureDefinition(configurationSection); + } + + return ParseFeatureDefinition(configurationSection); + } + + private FeatureDefinition ParseFeatureDefinition(IConfigurationSection configurationSection) { /* @@ -167,15 +220,17 @@ We support */ - RequirementType requirementType = RequirementType.Any; + string featureName = GetFeatureName(configurationSection); var enabledFor = new List(); + RequirementType requirementType = RequirementType.Any; + string val = configurationSection.Value; // configuration[$"{featureName}"]; if (string.IsNullOrEmpty(val)) { - val = configurationSection[FeatureFiltersSectionName]; + val = configurationSection[ConfigurationFields.FeatureFiltersSectionName]; } if (!string.IsNullOrEmpty(val) && bool.TryParse(val, out bool result) && result) @@ -193,30 +248,30 @@ We support } else { - string rawRequirementType = configurationSection[RequirementTypeKeyword]; + string rawRequirementType = configurationSection[ConfigurationFields.RequirementType]; // // If requirement type is specified, parse it and set the requirementType variable if (!string.IsNullOrEmpty(rawRequirementType) && !Enum.TryParse(rawRequirementType, ignoreCase: true, out requirementType)) { throw new FeatureManagementException( - FeatureManagementError.InvalidConfigurationSetting, - $"Invalid requirement type '{rawRequirementType}' for feature '{configurationSection.Key}'."); + FeatureManagementError.InvalidConfigurationSetting, + $"Invalid value '{rawRequirementType}' for field '{ConfigurationFields.RequirementType}' of feature '{featureName}'."); } - IEnumerable filterSections = configurationSection.GetSection(FeatureFiltersSectionName).GetChildren(); + IEnumerable filterSections = configurationSection.GetSection(ConfigurationFields.FeatureFiltersSectionName).GetChildren(); foreach (IConfigurationSection section in filterSections) { // // Arrays in json such as "myKey": [ "some", "values" ] // Are accessed through the configuration system by using the array index as the property name, e.g. "myKey": { "0": "some", "1": "values" } - if (int.TryParse(section.Key, out int i) && !string.IsNullOrEmpty(section[nameof(FeatureFilterConfiguration.Name)])) + if (int.TryParse(section.Key, out int _) && !string.IsNullOrEmpty(section[ConfigurationFields.NameKeyword])) { enabledFor.Add(new FeatureFilterConfiguration() { - Name = section[nameof(FeatureFilterConfiguration.Name)], - Parameters = new ConfigurationWrapper(section.GetSection(nameof(FeatureFilterConfiguration.Parameters))) + Name = section[ConfigurationFields.NameKeyword], + Parameters = new ConfigurationWrapper(section.GetSection(ConfigurationFields.FeatureFilterConfigurationParameters)) }); } } @@ -224,33 +279,184 @@ We support return new FeatureDefinition() { - Name = configurationSection.Key, + Name = featureName, EnabledFor = enabledFor, RequirementType = requirementType }; } - private IEnumerable GetFeatureDefinitionSections() + private FeatureDefinition ParseMicrosoftFeatureDefinition(IConfigurationSection configurationSection) { + /* + + If Microsoft feature flag schema is enabled, we support + + FeatureFlags: [ + { + id: "myFeature", + enabled: true, + conditions: { + client_filters: ["myFeatureFilter1", "myFeatureFilter2"], + requirement_type: "All", + } + }, + { + id: "myAlwaysEnabledFeature", + enabled: true, + conditions: { + client_filters: [], + } + }, + { + id: "myAlwaysDisabledFeature", + enabled: false, + } + ] + + */ + + string featureName = GetFeatureName(configurationSection); + + var enabledFor = new List(); + + RequirementType requirementType = RequirementType.Any; + + IConfigurationSection conditions = configurationSection.GetSection(MicrosoftFeatureFlagFields.Conditions); + + string rawRequirementType = conditions[MicrosoftFeatureFlagFields.RequirementType]; + // - // Look for feature definitions under the "FeatureManagement" section - IConfigurationSection featureManagementConfigurationSection = _configuration.GetSection(FeatureManagementSectionName); + // If requirement type is specified, parse it and set the requirementType variable + if (!string.IsNullOrEmpty(rawRequirementType) && !Enum.TryParse(rawRequirementType, ignoreCase: true, out requirementType)) + { + throw new FeatureManagementException( + FeatureManagementError.InvalidConfigurationSetting, + $"Invalid value '{rawRequirementType}' for field '{MicrosoftFeatureFlagFields.RequirementType}' of feature '{featureName}'."); + } + + string rawEnabled = configurationSection[MicrosoftFeatureFlagFields.Enabled]; - if (featureManagementConfigurationSection.Exists()) + bool enabled = false; + + if (!string.IsNullOrEmpty(rawEnabled) && !bool.TryParse(rawEnabled, out enabled)) { - return featureManagementConfigurationSection.GetChildren(); + throw new FeatureManagementException( + FeatureManagementError.InvalidConfigurationSetting, + $"Invalid value '{rawEnabled}' for field '{MicrosoftFeatureFlagFields.Enabled}' of feature '{featureName}'."); } - // - // There is no "FeatureManagement" section in the configuration - if (RootConfigurationFallbackEnabled) + if (enabled) { - return _configuration.GetChildren(); + IEnumerable filterSections = conditions.GetSection(MicrosoftFeatureFlagFields.ClientFilters).GetChildren(); + + if (filterSections.Any()) + { + foreach (IConfigurationSection section in filterSections) + { + // + // Arrays in json such as "myKey": [ "some", "values" ] + // Are accessed through the configuration system by using the array index as the property name, e.g. "myKey": { "0": "some", "1": "values" } + if (int.TryParse(section.Key, out int _) && !string.IsNullOrEmpty(section[MicrosoftFeatureFlagFields.Name])) + { + enabledFor.Add(new FeatureFilterConfiguration() + { + Name = section[MicrosoftFeatureFlagFields.Name], + Parameters = new ConfigurationWrapper(section.GetSection(MicrosoftFeatureFlagFields.Parameters)) + }); + } + } + } + else + { + enabledFor.Add(new FeatureFilterConfiguration + { + Name = "AlwaysOn" + }); + } } - Logger?.LogDebug($"No configuration section named '{FeatureManagementSectionName}' was found."); + return new FeatureDefinition() + { + Name = featureName, + EnabledFor = enabledFor, + RequirementType = requirementType + }; + } + + private string GetFeatureName(IConfigurationSection section) + { + if (_microsoftFeatureFlagSchemaEnabled) + { + return section[MicrosoftFeatureFlagFields.Id]; + } + + return section.Key; + } + + private IEnumerable GetFeatureDefinitionSections() + { + if (!_configuration.GetChildren().Any()) + { + Logger?.LogDebug($"Configuration is empty."); + + return Enumerable.Empty(); + } + + IConfiguration featureManagementConfigurationSection = _configuration + .GetChildren() + .FirstOrDefault(section => + string.Equals( + section.Key, + ConfigurationFields.FeatureManagementSectionName, + StringComparison.OrdinalIgnoreCase)); + + if (featureManagementConfigurationSection == null) + { + if (RootConfigurationFallbackEnabled) + { + featureManagementConfigurationSection = _configuration; + } + else + { + Logger?.LogDebug($"No configuration section named '{ConfigurationFields.FeatureManagementSectionName}' was found."); + + return Enumerable.Empty(); + } + } + + if (_microsoftFeatureFlagSchemaEnabled) + { + IConfigurationSection featureFlagsSection = featureManagementConfigurationSection.GetSection(MicrosoftFeatureFlagFields.FeatureFlagsSectionName); + + return featureFlagsSection.GetChildren(); + } + + return featureManagementConfigurationSection.GetChildren(); + } + + private static bool HasMicrosoftFeatureFlagSchema(IConfiguration featureManagementConfiguration) + { + IConfigurationSection featureFlagsConfigurationSection = featureManagementConfiguration + .GetChildren() + .FirstOrDefault(section => + string.Equals( + section.Key, + MicrosoftFeatureFlagFields.FeatureFlagsSectionName, + StringComparison.OrdinalIgnoreCase)); + + if (featureFlagsConfigurationSection != null) + { + if (!string.IsNullOrEmpty(featureFlagsConfigurationSection.Value)) + { + return false; + } + + IEnumerable featureFlagsChildren = featureFlagsConfigurationSection.GetChildren(); + + return featureFlagsChildren.Any() && featureFlagsChildren.All(section => int.TryParse(section.Key, out int _)); + } - return Enumerable.Empty(); + return false; } } } diff --git a/src/Microsoft.FeatureManagement/ConfigurationFields.cs b/src/Microsoft.FeatureManagement/ConfigurationFields.cs new file mode 100644 index 00000000..a5472cd8 --- /dev/null +++ b/src/Microsoft.FeatureManagement/ConfigurationFields.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.FeatureManagement +{ + internal static class ConfigurationFields + { + // Enum keywords + public const string RequirementType = "RequirementType"; + + // Feature filters keywords + public const string FeatureFiltersSectionName = "EnabledFor"; + public const string FeatureFilterConfigurationParameters = "Parameters"; + + // Other keywords + public const string NameKeyword = "Name"; + public const string FeatureManagementSectionName = "FeatureManagement"; + } +} \ No newline at end of file diff --git a/src/Microsoft.FeatureManagement/MicrosoftFeatureFlagFields.cs b/src/Microsoft.FeatureManagement/MicrosoftFeatureFlagFields.cs new file mode 100644 index 00000000..21286e5c --- /dev/null +++ b/src/Microsoft.FeatureManagement/MicrosoftFeatureFlagFields.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.FeatureManagement +{ + // + // Microsoft feature flag schema: https://github.com/Azure/AppConfiguration/blob/main/docs/FeatureManagement/FeatureFlag.v1.1.0.schema.json + internal static class MicrosoftFeatureFlagFields + { + public const string FeatureFlagsSectionName = "FeatureFlags"; + + // + // Feature flag keywords + public const string Id = "id"; + public const string Enabled = "enabled"; + public const string Conditions = "conditions"; + public const string ClientFilters = "client_filters"; + public const string RequirementType = "requirement_type"; + + // + // Client filter keywords + public const string Name = "name"; + public const string Parameters = "parameters"; + } +} \ No newline at end of file diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index 15d0b062..2921c341 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -106,6 +106,203 @@ public async Task ReadsTopLevelConfiguration() Assert.True(await featureManager.IsEnabledAsync(feature)); } + [Fact] + public async Task ReadsFeatureFlagsArraySchema() + { + string json = @" + { + ""AllowedHosts"": ""*"", + ""FeatureManagement"": { + ""MyFeature"": true, + ""FeatureFlags"": [ + { + ""id"": ""Alpha"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [] + } + }, + { + ""id"": ""Beta"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Percentage"", + ""parameters"": { + ""Value"": 100 + } + }, + { + ""name"": ""Targeting"", + ""parameters"": { + ""Audience"": { + ""Users"": [""Jeff""], + ""Groups"": [], + ""DefaultRolloutPercentage"": 0 + } + } + } + ], + ""requirement_type"" : ""all"" + } + }, + { + ""id"": ""Sigma"", + ""enabled"": false, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Percentage"", + ""parameters"": { + ""Value"": 100 + } + } + ] + } + }, + { + ""id"": ""Omega"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Percentage"", + ""parameters"": { + ""Value"": 100 + } + }, + { + ""name"": ""Percentage"", + ""parameters"": { + ""Value"": 0 + } + } + ] + } + } + ] + } + }"; + + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + IConfiguration config = new ConfigurationBuilder().AddJsonStream(stream).Build(); + + var services = new ServiceCollection(); + + services.AddSingleton(config) + .AddFeatureManagement(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + Assert.False(await featureManager.IsEnabledAsync("MyFeature")); + + Assert.True(await featureManager.IsEnabledAsync("Alpha")); + + Assert.True(await featureManager.IsEnabledAsync("Beta", new TargetingContext + { + UserId = "Jeff" + })); + + Assert.False(await featureManager.IsEnabledAsync("Beta", new TargetingContext + { + UserId = "Sam" + })); + + Assert.False(await featureManager.IsEnabledAsync("Sigma")); + + Assert.True(await featureManager.IsEnabledAsync("Omega")); + + json = @" + { + ""AllowedHosts"": ""*"", + ""FeatureManagement"": { + ""MyFeature"": true, + ""FeatureFlags"": [ + { + ""id"": ""Alpha"", + ""enabled"": true + } + ] + } + }"; + + stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + config = new ConfigurationBuilder().AddJsonStream(stream).Build(); + + services = new ServiceCollection(); + + services.AddFeatureManagement(config.GetSection("FeatureManagement")); + + serviceProvider = services.BuildServiceProvider(); + + featureManager = serviceProvider.GetRequiredService(); + + Assert.False(await featureManager.IsEnabledAsync("MyFeature")); + + Assert.True(await featureManager.IsEnabledAsync("Alpha")); + + json = @" + { + ""AllowedHosts"": ""*"", + ""FeatureManagement"": { + ""MyFeature"": true, + ""FeatureFlags"": true + } + }"; + + stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + config = new ConfigurationBuilder().AddJsonStream(stream).Build(); + + services = new ServiceCollection(); + + services.AddFeatureManagement(config.GetSection("FeatureManagement")); + + serviceProvider = services.BuildServiceProvider(); + + featureManager = serviceProvider.GetRequiredService(); + + Assert.True(await featureManager.IsEnabledAsync("MyFeature")); + + Assert.True(await featureManager.IsEnabledAsync("FeatureFlags")); + + json = @" + { + ""AllowedHosts"": ""*"", + ""FeatureManagement"": { + ""MyFeature"": true, + ""FeatureFlags"": { + ""EnabledFor"": [ + { + ""Name"": ""AlwaysOn"" + } + ] + } + } + }"; + + stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + config = new ConfigurationBuilder().AddJsonStream(stream).Build(); + + services = new ServiceCollection(); + + services.AddFeatureManagement(config.GetSection("FeatureManagement")); + + serviceProvider = services.BuildServiceProvider(); + + featureManager = serviceProvider.GetRequiredService(); + + Assert.True(await featureManager.IsEnabledAsync("MyFeature")); + + Assert.True(await featureManager.IsEnabledAsync("FeatureFlags")); + } + [Fact] public void AddsScopedFeatureManagement() {