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

Allow different type of filters to share the same Alias #262

Merged
merged 10 commits into from
Oct 26, 2023
104 changes: 69 additions & 35 deletions src/Microsoft.FeatureManagement/FeatureManager.cs
Original file line number Diff line number Diff line change
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
Contributor

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
Contributor 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);
}
else
{
filter = GetFeatureFilterMetadata(featureFilterConfiguration.Name);
}

if (filter == null)
{
Expand All @@ -163,14 +173,14 @@ private async Task<bool> IsEnabledAsync<TContext>(string feature, TContext appCo
Parameters = featureFilterConfiguration.Parameters
};

BindSettings(filter, context, filterIndex);

//
// 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)
{
ContextualFeatureFilterEvaluator contextualFilter = GetContextualFeatureFilter(featureFilterConfiguration.Name, typeof(TContext));

BindSettings(filter, context, filterIndex);

if (contextualFilter != null &&
await contextualFilter.EvaluateAsync(context, appContext).ConfigureAwait(false) == targetEvaluation)
{
Expand All @@ -184,9 +194,8 @@ await contextualFilter.EvaluateAsync(context, appContext).ConfigureAwait(false)
// IFeatureFilter
if (filter is IFeatureFilter featureFilter)
{
BindSettings(filter, context, filterIndex);

if (await featureFilter.EvaluateAsync(context).ConfigureAwait(false) == targetEvaluation) {
if (await featureFilter.EvaluateAsync(context).ConfigureAwait(false) == targetEvaluation)
{
enabled = targetEvaluation;

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

private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName)
private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName, Type appContextType = null)
{
const string filterSuffix = "filter";

IFeatureFilterMetadata filter = _filterMetadataCache.GetOrAdd(
filterName,
$"{filterName}{Environment.NewLine}{appContextType?.FullName}",
(_) => {

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

string name = ((FilterAliasAttribute)Attribute.GetCustomAttribute(t, typeof(FilterAliasAttribute)))?.Alias;
Type filterType = f.GetType();

zhiyuanliang-ms marked this conversation as resolved.
Show resolved Hide resolved
if (name == null)
if (!IsMatchingName(filterType, filterName))
{
name = t.Name.EndsWith(filterSuffix, StringComparison.OrdinalIgnoreCase) ? t.Name.Substring(0, t.Name.Length - filterSuffix.Length) : t.Name;
return false;
}

//
// 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('.'))
if (appContextType == null)
{
//
// The configured filter name is namespaced. It must be an exact match.
return string.Equals(name, filterName, StringComparison.OrdinalIgnoreCase);
return (f is IFeatureFilter);
}
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);
}
return ContextualFeatureFilterEvaluator.IsContextualFilter(f, appContextType);
});

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 @@ -318,6 +318,37 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName)
return filter;
}

private bool IsMatchingName(Type filterType, string filterName)
{
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'
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 ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filterName, Type appContextType)
{
if (appContextType == null)
Expand All @@ -329,11 +360,14 @@ private ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filte
$"{filterName}{Environment.NewLine}{appContextType.FullName}",
(_) => {

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

if (metadata == null)
{
return null;
}

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

Expand Down
116 changes: 115 additions & 1 deletion tests/Tests.FeatureManagement/FeatureManagement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,120 @@ 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>();

var appContext = new AppContext();

var dummyContext = new DummyContext();

var targetingContext = new TargetingContext();

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

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

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

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

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>();

var 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 +289,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);
}
}
}
Loading