Skip to content
100 changes: 66 additions & 34 deletions src/Microsoft.FeatureManagement/FeatureManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ class FeatureManager : IFeatureManager, IDisposable
private readonly IEnumerable<IFeatureFilterMetadata> _featureFilters;
private readonly IEnumerable<ISessionManager> _sessionManagers;
private readonly ILogger _logger;
private readonly ConcurrentDictionary<string, IFeatureFilterMetadata> _filterMetadataCache;
private readonly ConcurrentDictionary<string, ContextualFeatureFilterEvaluator> _contextualFeatureFilterCache;
private readonly ConcurrentDictionary<ValueTuple<string, Type>, IFeatureFilterMetadata> _filterMetadataCache;
private readonly ConcurrentDictionary<ValueTuple<string, Type>, ContextualFeatureFilterEvaluator> _contextualFeatureFilterCache;
private readonly FeatureManagementOptions _options;
private readonly IMemoryCache _parametersCache;

Expand All @@ -48,8 +48,8 @@ public FeatureManager(
_featureFilters = featureFilters ?? throw new ArgumentNullException(nameof(featureFilters));
_sessionManagers = sessionManagers ?? throw new ArgumentNullException(nameof(sessionManagers));
_logger = loggerFactory.CreateLogger<FeatureManager>();
_filterMetadataCache = new ConcurrentDictionary<string, IFeatureFilterMetadata>();
_contextualFeatureFilterCache = new ConcurrentDictionary<string, ContextualFeatureFilterEvaluator>();
_filterMetadataCache = new ConcurrentDictionary<ValueTuple<string, Type>, IFeatureFilterMetadata>();
_contextualFeatureFilterCache = new ConcurrentDictionary<ValueTuple<string, Type>, ContextualFeatureFilterEvaluator>();
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_parametersCache = new MemoryCache(new MemoryCacheOptions());
}
Expand Down Expand Up @@ -141,7 +141,17 @@ private async Task<bool> IsEnabledAsync<TContext>(string feature, TContext appCo
continue;
}

IFeatureFilterMetadata filter = GetFeatureFilterMetadata(featureFilterConfiguration.Name);
IFeatureFilterMetadata filter;

if (useAppContext)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're now saying a call to IsEnabledAsync("name", Context); will use "name" even if it has no context, then I don't think the useAppContext bool needs to exist anymore.

Just use contextual filter if its defined for the context, otherwise use the no contextual filter.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Context may be null. I think we still need to distinguish the case that users intend to pass null as the context.

{
filter = GetFeatureFilterMetadata(featureFilterConfiguration.Name, typeof(TContext)) ??
GetFeatureFilterMetadata(featureFilterConfiguration.Name, null);
}
else
{
filter = GetFeatureFilterMetadata(featureFilterConfiguration.Name, null);
}

if (filter == null)
{
Expand All @@ -165,7 +175,7 @@ private async Task<bool> IsEnabledAsync<TContext>(string feature, TContext appCo

//
// IContextualFeatureFilter
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (useAppContext && ContextualFeatureFilterEvaluator.IsContextualFilter(filter, typeof(TContext)))

At this point we should already know whether the filter is a contextual filter or not by whether filter was populated with or without an app context.

if (useAppContext)
if (useAppContext && ContextualFeatureFilterEvaluator.IsContextualFilter(filter, typeof(TContext)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the IsContextualFilter function written like it is? Couldn't it just be interfaceType.IsAssignableFrom(classType) ? I know it's not a part of this PR, but curious.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a staic method IsContextualFilter() of ContextualFeatureFilterEvaluator.
In my previous commit, my code looked like:
if (useAppContext && ! (filter is IFeatureFilter))
I think it is better to use IsContextualFilter() because it is more readable.

{
ContextualFeatureFilterEvaluator contextualFilter = GetContextualFeatureFilter(featureFilterConfiguration.Name, typeof(TContext));

Expand Down Expand Up @@ -267,48 +277,72 @@ private void BindSettings(IFeatureFilterMetadata filter, FeatureFilterEvaluation
context.Settings = settings;
}

private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName)
private bool IsFilterNameMatched(Type filterType, string filterName)
Copy link
Member

@jimmyca15 jimmyca15 Oct 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to move after the private methods that call it to preserve consistent scroll-flow.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private bool IsFilterNameMatched(Type filterType, string filterName)
private bool IsMatchingName(Type filterType, string filterName)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see an update to the name

{
const string filterSuffix = "filter";

string name = ((FilterAliasAttribute)Attribute.GetCustomAttribute(filterType, typeof(FilterAliasAttribute)))?.Alias;

if (name == null)
{
name = filterType.Name.EndsWith(filterSuffix, StringComparison.OrdinalIgnoreCase) ? filterType.Name.Substring(0, filterType.Name.Length - filterSuffix.Length) : filterType.Name;
}

//
// Feature filters can have namespaces in their alias
// If a feature is configured to use a filter without a namespace such as 'MyFilter', then it can match 'MyOrg.MyProduct.MyFilter' or simply 'MyFilter'
// If a feature is configured to use a filter with a namespace such as 'MyOrg.MyProduct.MyFilter' then it can only match 'MyOrg.MyProduct.MyFilter'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, what if there is a filter MyProduct.MyFilter as well as MyOrg.MyProduct.MyFilter? We treat them as distinct names?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. If the configuration of a feature flag contains any filters with a namespace, the feature manager will try to find a registered filter whose alias is the same as the filter name in the feature flag configuration.

if (filterName.Contains('.'))
{
//
// The configured filter name is namespaced. It must be an exact match.
return string.Equals(name, filterName, StringComparison.OrdinalIgnoreCase);
}
else
{
//
// We take the simple name of a filter, E.g. 'MyFilter' for 'MyOrg.MyProduct.MyFilter'
string simpleName = name.Contains('.') ? name.Split('.').Last() : name;

return string.Equals(simpleName, filterName, StringComparison.OrdinalIgnoreCase);
}
}

private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName, Type appContextType)
{
IFeatureFilterMetadata filter = _filterMetadataCache.GetOrAdd(
filterName,
(filterName, appContextType),
(_) => {

IEnumerable<IFeatureFilterMetadata> matchingFilters = _featureFilters.Where(f =>
{
Type t = f.GetType();
Type filterType = f.GetType();

string name = ((FilterAliasAttribute)Attribute.GetCustomAttribute(t, typeof(FilterAliasAttribute)))?.Alias;

if (name == null)
if (appContextType == null)
{
name = t.Name.EndsWith(filterSuffix, StringComparison.OrdinalIgnoreCase) ? t.Name.Substring(0, t.Name.Length - filterSuffix.Length) : t.Name;
}

//
// Feature filters can have namespaces in their alias
// If a feature is configured to use a filter without a namespace such as 'MyFilter', then it can match 'MyOrg.MyProduct.MyFilter' or simply 'MyFilter'
// If a feature is configured to use a filter with a namespace such as 'MyOrg.MyProduct.MyFilter' then it can only match 'MyOrg.MyProduct.MyFilter'
if (filterName.Contains('.'))
{
//
// The configured filter name is namespaced. It must be an exact match.
return string.Equals(name, filterName, StringComparison.OrdinalIgnoreCase);
return (f is IFeatureFilter) && IsFilterNameMatched(filterType, filterName);
}
else
{
//
// We take the simple name of a filter, E.g. 'MyFilter' for 'MyOrg.MyProduct.MyFilter'
string simpleName = name.Contains('.') ? name.Split('.').Last() : name;
IEnumerable<Type> contextualFilterInterfaces = filterType.GetInterfaces().Where(
i => i.IsGenericType &&
i.GetGenericTypeDefinition().IsAssignableFrom(typeof(IContextualFeatureFilter<>)) &&
i.GetGenericArguments()[0].IsAssignableFrom(appContextType));

return string.Equals(simpleName, filterName, StringComparison.OrdinalIgnoreCase);
return contextualFilterInterfaces.Any() && IsFilterNameMatched(filterType, filterName);
}
});

if (matchingFilters.Count() > 1)
{
throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureFilter, $"Multiple feature filters match the configured filter named '{filterName}'.");
if (appContextType == null)
{
throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureFilter, $"Multiple feature filters match the configured filter named '{filterName}'.");
}
else
{
throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureFilter, $"Multiple contextual feature filters match the configured filter named '{filterName}' and context type '{appContextType}'.");
}
}

return matchingFilters.FirstOrDefault();
Expand All @@ -326,14 +360,12 @@ private ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filte
}

ContextualFeatureFilterEvaluator filter = _contextualFeatureFilterCache.GetOrAdd(
$"{filterName}{Environment.NewLine}{appContextType.FullName}",
(filterName, appContextType),
(_) => {

IFeatureFilterMetadata metadata = GetFeatureFilterMetadata(filterName);
IFeatureFilterMetadata metadata = GetFeatureFilterMetadata(filterName, appContextType);

return ContextualFeatureFilterEvaluator.IsContextualFilter(metadata, appContextType) ?
new ContextualFeatureFilterEvaluator(metadata, appContextType) :
null;
return new ContextualFeatureFilterEvaluator(metadata, appContextType);
}
);

Expand Down
112 changes: 111 additions & 1 deletion tests/Tests.FeatureManagement/FeatureManagement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,116 @@ public async Task ReadsOnlyFeatureManagementSection()
}
}

[Fact]
public async Task AllowDuplicatedFilterAlias()
{
const string duplicatedFilterName = "DuplicatedFilterName";

string featureName = Enum.GetName(typeof(Features), Features.FeatureUsesFiltersWithDuplicatedAlias);

IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();

var services = new ServiceCollection();

services
.AddSingleton(config)
.AddFeatureManagement()
.AddFeatureFilter<DuplicatedAliasFeatureFilter1>()
.AddFeatureFilter<ContextualDuplicatedAliasFeatureFilterWithAccountContext>()
.AddFeatureFilter<ContextualDuplicatedAliasFeatureFilterWithDummyContext1>()
.AddFeatureFilter<PercentageFilter>();

ServiceProvider serviceProvider = services.BuildServiceProvider();

IFeatureManager featureManager = serviceProvider.GetRequiredService<IFeatureManager>();

AppContext appContext = new AppContext();

DummyContext dummyContext = new DummyContext();

Assert.True(await featureManager.IsEnabledAsync(featureName));

Assert.True(await featureManager.IsEnabledAsync(featureName, appContext));

Assert.True(await featureManager.IsEnabledAsync(featureName, dummyContext));

services = new ServiceCollection();

services
.AddSingleton(config)
.AddFeatureManagement()
.AddFeatureFilter<DuplicatedAliasFeatureFilter1>()
.AddFeatureFilter<PercentageFilter>();

serviceProvider = services.BuildServiceProvider();

featureManager = serviceProvider.GetRequiredService<IFeatureManager>();

Assert.True(await featureManager.IsEnabledAsync(featureName, dummyContext));

services = new ServiceCollection();

services
.AddSingleton(config)
.AddFeatureManagement()
.AddFeatureFilter<DuplicatedAliasFeatureFilter1>()
.AddFeatureFilter<DuplicatedAliasFeatureFilter2>()
.AddFeatureFilter<PercentageFilter>();

serviceProvider = services.BuildServiceProvider();

featureManager = serviceProvider.GetRequiredService<IFeatureManager>();

FeatureManagementException ex = await Assert.ThrowsAsync<FeatureManagementException>(
async () =>
{
await featureManager.IsEnabledAsync(featureName);
});

Assert.Equal($"Multiple feature filters match the configured filter named '{duplicatedFilterName}'.", ex.Message);

services = new ServiceCollection();

services
.AddSingleton(config)
.AddFeatureManagement()
.AddFeatureFilter<ContextualDuplicatedAliasFeatureFilterWithDummyContext1>()
.AddFeatureFilter<ContextualDuplicatedAliasFeatureFilterWithDummyContext2>()
.AddFeatureFilter<PercentageFilter>();

serviceProvider = services.BuildServiceProvider();

featureManager = serviceProvider.GetRequiredService<IFeatureManager>();

ex = await Assert.ThrowsAsync<FeatureManagementException>(
async () =>
{
await featureManager.IsEnabledAsync(featureName, dummyContext);
});

Assert.Equal($"Multiple contextual feature filters match the configured filter named '{duplicatedFilterName}' and context type '{typeof(DummyContext)}'.", ex.Message);

services = new ServiceCollection();

services
.AddSingleton(config)
.AddFeatureManagement()
.AddFeatureFilter<ContextualDuplicatedAliasFeatureFilterWithAccountContext>()
.AddFeatureFilter<PercentageFilter>();

serviceProvider = services.BuildServiceProvider();

featureManager = serviceProvider.GetRequiredService<IFeatureManager>();

ex = await Assert.ThrowsAsync<FeatureManagementException>(
async () =>
{
await featureManager.IsEnabledAsync(featureName);
});

Assert.Equal($"The feature filter '{duplicatedFilterName}' specified for feature '{featureName}' was not found.", ex.Message);
}

[Fact]
public async Task CustomFilterContextualTargetingWithNullSetting()
{
Expand Down Expand Up @@ -175,7 +285,7 @@ public async Task Percentage()
}
}

Assert.True(enabledCount > 0 && enabledCount < 10);
Assert.True(enabledCount >= 0 && enabledCount < 10);
}

[Fact]
Expand Down
3 changes: 2 additions & 1 deletion tests/Tests.FeatureManagement/Features.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ enum Features
ConditionalFeature2,
ContextualFeature,
AnyFilterFeature,
AllFilterFeature
AllFilterFeature,
FeatureUsesFiltersWithDuplicatedAlias
}
}
73 changes: 73 additions & 0 deletions tests/Tests.FeatureManagement/FiltersWithDuplicatedAlias.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.FeatureManagement;
using System.Threading.Tasks;

namespace Tests.FeatureManagement
{
interface IDummyContext
{
string DummyProperty { get; set; }
}

class DummyContext : IDummyContext
{
public string DummyProperty { get; set; }
}

[FilterAlias(Alias)]
class DuplicatedAliasFeatureFilter1 : IFeatureFilter
{
private const string Alias = "DuplicatedFilterName";

public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
return Task.FromResult(true);
}
}

[FilterAlias(Alias)]
class DuplicatedAliasFeatureFilter2 : IFeatureFilter
{
private const string Alias = "DuplicatedFilterName";

public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
return Task.FromResult(true);
}
}

[FilterAlias(Alias)]
class ContextualDuplicatedAliasFeatureFilterWithAccountContext : IContextualFeatureFilter<IAccountContext>
{
private const string Alias = "DuplicatedFilterName";

public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context, IAccountContext accountContext)
{
return Task.FromResult(true);
}
}

[FilterAlias(Alias)]
class ContextualDuplicatedAliasFeatureFilterWithDummyContext1 : IContextualFeatureFilter<IDummyContext>
{
private const string Alias = "DuplicatedFilterName";

public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context, IDummyContext dummyContext)
{
return Task.FromResult(true);
}
}

[FilterAlias(Alias)]
class ContextualDuplicatedAliasFeatureFilterWithDummyContext2 : IContextualFeatureFilter<IDummyContext>
{
private const string Alias = "DuplicatedFilterName";

public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context, IDummyContext dummyContext)
{
return Task.FromResult(true);
}
}
}
14 changes: 14 additions & 0 deletions tests/Tests.FeatureManagement/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@
"FeatureManagement": {
"OnTestFeature": true,
"OffTestFeature": false,
"FeatureUsesFiltersWithDuplicatedAlias": {
"RequirementType": "all",
"EnabledFor": [
{
"Name": "DuplicatedFilterName"
},
{
"Name": "Percentage",
"Parameters": {
"Value": 100
}
}
]
},
"TargetingTestFeature": {
"EnabledFor": [
{
Expand Down