Generalize Action Selection logic

Allows us to use the "select action via route values" logic for
endpoints.
This commit is contained in:
Ryan Nowak 2019-02-28 14:27:36 -08:00
parent 8a46e8cd93
commit bb28db6fb2
6 changed files with 878 additions and 197 deletions

View File

@ -8,6 +8,8 @@ using Microsoft.AspNetCore.Http;
namespace Microsoft.AspNetCore.Routing
{
// FYI: This class is also linked into MVC. If you make changes to the API you will
// also need to change MVC's usage.
internal sealed class DataSourceDependentCache<T> : IDisposable where T : class
{
private readonly EndpointDataSource _dataSource;

View File

@ -0,0 +1,180 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Routing;
namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
// Common infrastructure for things that look up actions by route values.
//
// The ActionSelectionTable stores a mapping of route-values -> items for each known set of
// of route-values. We actually build two of these mappings, one for case-sensitive (fast path) and one for
// case-insensitive (slow path).
//
// This is necessary because MVC routing/action-selection is always case-insensitive. So we're going to build
// a case-sensitive dictionary that will behave like the a case-insensitive dictionary when you hit one of the
// canonical entries. When you don't hit a case-sensitive match it will try the case-insensitive dictionary
// so you still get correct behaviors.
//
// The difference here is because while MVC is case-insensitive, doing a case-sensitive comparison is much
// faster. We also expect that most of the URLs we process are canonically-cased because they were generated
// by Url.Action or another routing api.
//
// This means that for a set of actions like:
// { controller = "Home", action = "Index" } -> HomeController::Index1()
// { controller = "Home", action = "index" } -> HomeController::Index2()
//
// Both of these actions match "Index" case-insensitively, but there exist two known canonical casings,
// so we will create an entry for "Index" and an entry for "index". Both of these entries match **both**
// actions.
internal class ActionSelectionTable<TItem>
{
private ActionSelectionTable(
int version,
string[] routeKeys,
Dictionary<string[], List<TItem>> ordinalEntries,
Dictionary<string[], List<TItem>> ordinalIgnoreCaseEntries)
{
Version = version;
RouteKeys = routeKeys;
OrdinalEntries = ordinalEntries;
OrdinalIgnoreCaseEntries = ordinalIgnoreCaseEntries;
}
public int Version { get; }
private string[] RouteKeys { get; }
private Dictionary<string[], List<TItem>> OrdinalEntries { get; }
private Dictionary<string[], List<TItem>> OrdinalIgnoreCaseEntries { get; }
public static ActionSelectionTable<ActionDescriptor> Create(ActionDescriptorCollection actions)
{
return CreateCore<ActionDescriptor>(
// We need to store the version so the cache can be invalidated if the actions change.
version: actions.Version,
// For action selection, ignore attribute routed actions
items: actions.Items.Where(a => a.AttributeRouteInfo == null),
getRouteKeys: a => a.RouteValues.Keys,
getRouteValue: (a, key) =>
{
a.RouteValues.TryGetValue(key, out var value);
return value ?? string.Empty;
});
}
public static ActionSelectionTable<RouteEndpoint> Create(IEnumerable<Endpoint> endpoints)
{
return CreateCore<RouteEndpoint>(
// we don't use version for endpoints
version: 0,
// Only include RouteEndpoints and only those that aren't suppressed.
items: endpoints.OfType<RouteEndpoint>().Where(e =>
{
return e.Metadata.GetMetadata<ISuppressMatchingMetadata>().SuppressMatching != true;
}),
getRouteKeys: e => e.RoutePattern.RequiredValues.Keys,
getRouteValue: (e, key) =>
{
e.RoutePattern.RequiredValues.TryGetValue(key, out var value);
return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
});
}
private static ActionSelectionTable<T> CreateCore<T>(
int version,
IEnumerable<T> items,
Func<T, IEnumerable<string>> getRouteKeys,
Func<T, string, string> getRouteValue)
{
// We need to build two maps for all of the route values.
var ordinalEntries = new Dictionary<string[], List<T>>(StringArrayComparer.Ordinal);
var ordinalIgnoreCaseEntries = new Dictionary<string[], List<T>>(StringArrayComparer.OrdinalIgnoreCase);
// We need to hold on to an ordered set of keys for the route values. We'll use these later to
// extract the set of route values from an incoming request to compare against our maps of known
// route values.
var routeKeys = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in items)
{
foreach (var key in getRouteKeys(item))
{
routeKeys.Add(key);
}
}
foreach (var item in items)
{
// This is a conventionally routed action - so we need to extract the route values associated
// with this action (in order) so we can store them in our dictionaries.
var index = 0;
var routeValues = new string[routeKeys.Count];
foreach (var key in routeKeys)
{
var value = getRouteValue(item, key);
routeValues[index++] = value;
}
if (!ordinalIgnoreCaseEntries.TryGetValue(routeValues, out var entries))
{
entries = new List<T>();
ordinalIgnoreCaseEntries.Add(routeValues, entries);
}
entries.Add(item);
// We also want to add the same (as in reference equality) list of actions to the ordinal entries.
// We'll keep updating `entries` to include all of the actions in the same equivalence class -
// meaning, all conventionally routed actions for which the route values are equal ignoring case.
//
// `entries` will appear in `OrdinalIgnoreCaseEntries` exactly once and in `OrdinalEntries` once
// for each variation of casing that we've seen.
if (!ordinalEntries.ContainsKey(routeValues))
{
ordinalEntries.Add(routeValues, entries);
}
}
return new ActionSelectionTable<T>(version, routeKeys.ToArray(), ordinalEntries, ordinalIgnoreCaseEntries);
}
public IReadOnlyList<TItem> Select(RouteValueDictionary values)
{
// Select works based on a string[] of the route values in a pre-calculated order. This code extracts
// those values in the correct order.
var routeKeys = RouteKeys;
var routeValues = new string[routeKeys.Length];
for (var i = 0; i < routeKeys.Length; i++)
{
values.TryGetValue(routeKeys[i], out var value);
routeValues[i] = value as string ?? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
}
// Now look up, first case-sensitive, then case-insensitive.
if (OrdinalEntries.TryGetValue(routeValues, out var matches) ||
OrdinalIgnoreCaseEntries.TryGetValue(routeValues, out matches))
{
Debug.Assert(matches != null);
Debug.Assert(matches.Count >= 0);
return matches;
}
return Array.Empty<TItem>();
}
}
}

View File

@ -3,15 +3,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Threading;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Internal;
using Microsoft.Extensions.Logging;
using Resources = Microsoft.AspNetCore.Mvc.Core.Resources;
@ -22,13 +18,11 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
/// </summary>
internal class ActionSelector : IActionSelector
{
private static readonly IReadOnlyList<ActionDescriptor> EmptyActions = Array.Empty<ActionDescriptor>();
private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider;
private readonly ActionConstraintCache _actionConstraintCache;
private readonly ILogger _logger;
private Cache _cache;
private ActionSelectionTable<ActionDescriptor> _cache;
/// <summary>
/// Creates a new <see cref="ActionSelector"/>.
@ -49,7 +43,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
_actionConstraintCache = actionConstraintCache;
}
private Cache Current
private ActionSelectionTable<ActionDescriptor> Current
{
get
{
@ -61,7 +55,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
return cache;
}
cache = new Cache(actions);
cache = ActionSelectionTable<ActionDescriptor>.Create(actions);
Volatile.Write(ref _cache, cache);
return cache;
}
@ -76,28 +70,14 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
var cache = Current;
// The Cache works based on a string[] of the route values in a pre-calculated order. This code extracts
// those values in the correct order.
var keys = cache.RouteKeys;
var values = new string[keys.Length];
for (var i = 0; i < keys.Length; i++)
var matches = cache.Select(context.RouteData.Values);
if (matches.Count > 0)
{
context.RouteData.Values.TryGetValue(keys[i], out var value);
if (value != null)
{
values[i] = value as string ?? Convert.ToString(value, CultureInfo.InvariantCulture);
}
}
if (cache.OrdinalEntries.TryGetValue(values, out var matchingRouteValues) ||
cache.OrdinalIgnoreCaseEntries.TryGetValue(values, out matchingRouteValues))
{
Debug.Assert(matchingRouteValues != null);
return matchingRouteValues;
return matches;
}
_logger.NoActionsMatched(context.RouteData.Values);
return EmptyActions;
return matches;
}
public ActionDescriptor SelectBestCandidate(RouteContext context, IReadOnlyList<ActionDescriptor> candidates)
@ -286,175 +266,5 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
return EvaluateActionConstraintsCore(context, actionsWithoutConstraint, order);
}
}
// The action selector cache stores a mapping of route-values -> action descriptors for each known set of
// of route-values. We actually build two of these mappings, one for case-sensitive (fast path) and one for
// case-insensitive (slow path).
//
// This is necessary because MVC routing/action-selection is always case-insensitive. So we're going to build
// a case-sensitive dictionary that will behave like the a case-insensitive dictionary when you hit one of the
// canonical entries. When you don't hit a case-sensitive match it will try the case-insensitive dictionary
// so you still get correct behaviors.
//
// The difference here is because while MVC is case-insensitive, doing a case-sensitive comparison is much
// faster. We also expect that most of the URLs we process are canonically-cased because they were generated
// by Url.Action or another routing api.
//
// This means that for a set of actions like:
// { controller = "Home", action = "Index" } -> HomeController::Index1()
// { controller = "Home", action = "index" } -> HomeController::Index2()
//
// Both of these actions match "Index" case-insensitively, but there exist two known canonical casings,
// so we will create an entry for "Index" and an entry for "index". Both of these entries match **both**
// actions.
private class Cache
{
public Cache(ActionDescriptorCollection actions)
{
// We need to store the version so the cache can be invalidated if the actions change.
Version = actions.Version;
// We need to build two maps for all of the route values.
OrdinalEntries = new Dictionary<string[], List<ActionDescriptor>>(StringArrayComparer.Ordinal);
OrdinalIgnoreCaseEntries = new Dictionary<string[], List<ActionDescriptor>>(StringArrayComparer.OrdinalIgnoreCase);
// We need to first identify of the keys that action selection will look at (in route data).
// We want to only consider conventionally routed actions here.
var routeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < actions.Items.Count; i++)
{
var action = actions.Items[i];
if (action.AttributeRouteInfo == null)
{
// This is a conventionally routed action - so make sure we include its keys in the set of
// known route value keys.
foreach (var kvp in action.RouteValues)
{
routeKeys.Add(kvp.Key);
}
}
}
// We need to hold on to an ordered set of keys for the route values. We'll use these later to
// extract the set of route values from an incoming request to compare against our maps of known
// route values.
RouteKeys = routeKeys.ToArray();
for (var i = 0; i < actions.Items.Count; i++)
{
var action = actions.Items[i];
if (action.AttributeRouteInfo != null)
{
// This only handles conventional routing. Ignore attribute routed actions.
continue;
}
// This is a conventionally routed action - so we need to extract the route values associated
// with this action (in order) so we can store them in our dictionaries.
var routeValues = new string[RouteKeys.Length];
for (var j = 0; j < RouteKeys.Length; j++)
{
action.RouteValues.TryGetValue(RouteKeys[j], out routeValues[j]);
}
if (!OrdinalIgnoreCaseEntries.TryGetValue(routeValues, out var entries))
{
entries = new List<ActionDescriptor>();
OrdinalIgnoreCaseEntries.Add(routeValues, entries);
}
entries.Add(action);
// We also want to add the same (as in reference equality) list of actions to the ordinal entries.
// We'll keep updating `entries` to include all of the actions in the same equivalence class -
// meaning, all conventionally routed actions for which the route values are equal ignoring case.
//
// `entries` will appear in `OrdinalIgnoreCaseEntries` exactly once and in `OrdinalEntries` once
// for each variation of casing that we've seen.
if (!OrdinalEntries.ContainsKey(routeValues))
{
OrdinalEntries.Add(routeValues, entries);
}
}
}
public int Version { get; }
public string[] RouteKeys { get; }
public Dictionary<string[], List<ActionDescriptor>> OrdinalEntries { get; }
public Dictionary<string[], List<ActionDescriptor>> OrdinalIgnoreCaseEntries { get; }
}
private class StringArrayComparer : IEqualityComparer<string[]>
{
public static readonly StringArrayComparer Ordinal = new StringArrayComparer(StringComparer.Ordinal);
public static readonly StringArrayComparer OrdinalIgnoreCase = new StringArrayComparer(StringComparer.OrdinalIgnoreCase);
private readonly StringComparer _valueComparer;
private StringArrayComparer(StringComparer valueComparer)
{
_valueComparer = valueComparer;
}
public bool Equals(string[] x, string[] y)
{
if (object.ReferenceEquals(x, y))
{
return true;
}
if (x == null ^ y == null)
{
return false;
}
if (x.Length != y.Length)
{
return false;
}
for (var i = 0; i < x.Length; i++)
{
if (string.IsNullOrEmpty(x[i]) && string.IsNullOrEmpty(y[i]))
{
continue;
}
if (!_valueComparer.Equals(x[i], y[i]))
{
return false;
}
}
return true;
}
public int GetHashCode(string[] obj)
{
if (obj == null)
{
return 0;
}
var hash = new HashCodeCombiner();
for (var i = 0; i < obj.Length; i++)
{
var o = obj[i];
// Route values define null and "" to be equivalent.
if (string.IsNullOrEmpty(o))
{
o = null;
}
hash.Add(o, _valueComparer);
}
return hash.CombinedHash;
}
}
}
}

View File

@ -0,0 +1,79 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Internal;
namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
internal class StringArrayComparer : IEqualityComparer<string[]>
{
public static readonly StringArrayComparer Ordinal = new StringArrayComparer(StringComparer.Ordinal);
public static readonly StringArrayComparer OrdinalIgnoreCase = new StringArrayComparer(StringComparer.OrdinalIgnoreCase);
private readonly StringComparer _valueComparer;
private StringArrayComparer(StringComparer valueComparer)
{
_valueComparer = valueComparer;
}
public bool Equals(string[] x, string[] y)
{
if (object.ReferenceEquals(x, y))
{
return true;
}
if (x == null ^ y == null)
{
return false;
}
if (x.Length != y.Length)
{
return false;
}
for (var i = 0; i < x.Length; i++)
{
if (string.IsNullOrEmpty(x[i]) && string.IsNullOrEmpty(y[i]))
{
continue;
}
if (!_valueComparer.Equals(x[i], y[i]))
{
return false;
}
}
return true;
}
public int GetHashCode(string[] obj)
{
if (obj == null)
{
return 0;
}
var hash = new HashCodeCombiner();
for (var i = 0; i < obj.Length; i++)
{
var o = obj[i];
// Route values define null and "" to be equivalent.
if (string.IsNullOrEmpty(o))
{
o = null;
}
hash.Add(o, _valueComparer);
}
return hash.CombinedHash;
}
}
}

View File

@ -27,6 +27,10 @@ Microsoft.AspNetCore.Mvc.RouteAttribute</Description>
<Compile Include="$(SharedSourceRoot)SecurityHelper\**\*.cs" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\..\Http\Routing\src\DataSourceDependentCache.cs" Link="Routing\DataSourceDependentCache.cs" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Mvc.Abstractions" />
<Reference Include="Microsoft.AspNetCore.Authentication.Core" />

View File

@ -0,0 +1,606 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.AspNetCore.Testing;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
// The ActionSelectionTable has different code paths for ActionDescriptor and
// RouteEndpoint for creating a table. We're trying to test both code paths
// for creation, but selection works the same for both cases.
public class ActionSelectionTableTest
{
[Fact]
public void Select_SingleMatch()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
},
new ActionDescriptor()
{
DisplayName = "A2",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "About" }
},
},
};
var table = CreateTableWithActionDescriptors(actions);
var values = new RouteValueDictionary(new { controller = "Home", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
Assert.Collection(matches, (a) => Assert.Same(actions[0], a));
}
[Fact]
[ReplaceCulture("de-CH", "de-CH")]
public void Select_ActionDescriptor_SingleMatch_UsesInvariantCulture()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" },
{ "date", "10/31/2018 07:37:38 -07:00" },
},
},
new ActionDescriptor()
{
DisplayName = "A2",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "About" }
},
},
};
var table = CreateTableWithActionDescriptors(actions);
var values = new RouteValueDictionary(new { controller = "Home", action = "Index", });
values.Add(
"date",
new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)));
// Act
var matches = table.Select(values);
// Assert
Assert.Collection(matches, (a) => Assert.Same(actions[0], a));
}
[Fact]
[ReplaceCulture("de-CH", "de-CH")]
public void Select_Endpoint_SingleMatch_UsesInvariantCulture()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" },
{ "date", "10/31/2018 07:37:38 -07:00" },
},
},
new ActionDescriptor()
{
DisplayName = "A2",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "About" }
},
},
};
var table = CreateTableWithEndpoints(actions);
var values = new RouteValueDictionary(new { controller = "Home", action = "Index", });
values.Add(
"date",
new DateTimeOffset(2018, 10, 31, 7, 37, 38, TimeSpan.FromHours(-7)));
// Act
var matches = table.Select(values);
// Assert
Assert.Collection(matches, (e) => Assert.Same(actions[0], e.Metadata.GetMetadata<ActionDescriptor>()));
}
[Fact]
public void Select_ActionDescriptor_MultipleMatches()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
},
new ActionDescriptor()
{
DisplayName = "A2",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
},
};
var table = CreateTableWithActionDescriptors(actions);
var values = new RouteValueDictionary(new { controller = "Home", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
Assert.Equal(actions.ToArray(), matches.ToArray());
}
[Fact]
public void Select_Endpoint_MultipleMatches()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
},
new ActionDescriptor()
{
DisplayName = "A2",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
},
};
var table = CreateTableWithEndpoints(actions);
var values = new RouteValueDictionary(new { controller = "Home", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
Assert.Equal(actions.ToArray(), matches.Select(e => e.Metadata.GetMetadata<ActionDescriptor>()).ToArray());
}
[Fact]
public void Select_NoMatch()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
},
new ActionDescriptor()
{
DisplayName = "A2",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "About" }
},
},
};
var table = CreateTableWithActionDescriptors(actions);
var values = new RouteValueDictionary(new { controller = "Foo", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
Assert.Empty(matches);
}
[Fact]
public void Select_ActionDescriptors_NoMatch_ExcludesAttributeRoutedActions()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
AttributeRouteInfo = new AttributeRouteInfo()
{
Template = "/Home",
}
},
};
var table = CreateTableWithActionDescriptors(actions);
var values = new RouteValueDictionary(new { controller = "Home", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
Assert.Empty(matches);
}
[Fact]
public void Select_Endpoint_Match_IncludesAttributeRoutedActions()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
AttributeRouteInfo = new AttributeRouteInfo()
{
Template = "/Home",
}
},
};
var table = CreateTableWithEndpoints(actions);
var values = new RouteValueDictionary(new { controller = "Home", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
Assert.Single(matches);
}
[Fact]
public void Select_Endpoint_NoMatch_ExcludesMatchingSuppressedAction()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
EndpointMetadata = new List<object>()
{
new SuppressMatchingMetadata(),
},
},
};
var table = CreateTableWithEndpoints(actions);
var values = new RouteValueDictionary(new { controller = "Home", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
Assert.Empty(matches);
}
// In this context `CaseSensitiveMatch` means that the input route values exactly match one of the action
// descriptor's route values in terms of casing. This is important because we optimize for this case
// in the implementation.
[Fact]
public void Select_Match_CaseSensitiveMatch_IncludesAllCaseInsensitiveMatches()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
},
new ActionDescriptor()
{
DisplayName = "A2",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "home" },
{ "action", "Index" }
},
},
new ActionDescriptor() // This won't match the request
{
DisplayName = "A3",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "About" }
},
},
};
var expected = actions.Take(2).ToArray();
var table = CreateTableWithActionDescriptors(actions);
var values = new RouteValueDictionary(new { controller = "Home", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
Assert.Equal(expected, matches);
}
// In this context `CaseInsensitiveMatch` means that the input route values do not match any action
// descriptor's route values in terms of casing. This is important because we optimize for the case
// where the casing matches - the non-matching-casing path is handled a bit differently.
[Fact]
public void Select_Match_CaseInsensitiveMatch_IncludesAllCaseInsensitiveMatches()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "Index" }
},
},
new ActionDescriptor()
{
DisplayName = "A2",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "home" },
{ "action", "Index" }
},
},
new ActionDescriptor() // This won't match the request
{
DisplayName = "A3",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "controller", "Home" },
{ "action", "About" }
},
},
};
var expected = actions.Take(2).ToArray();
var table = CreateTableWithActionDescriptors(actions);
var values = new RouteValueDictionary(new { controller = "HOME", action = "iNDex", });
// Act
var matches = table.Select(values);
// Assert
Assert.Equal(expected, matches);
}
[Fact]
public void Select_Match_CaseSensitiveMatch_MatchesOnEmptyString()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "area", null },
{ "controller", "Home" },
{ "action", "Index" }
},
}
};
var table = CreateTableWithActionDescriptors(actions);
// Example: In conventional route, one could set non-inline defaults
// new { area = "", controller = "Home", action = "Index" }
var values = new RouteValueDictionary(new { area = "", controller = "Home", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
var action = Assert.Single(matches);
Assert.Same(actions[0], action);
}
[Fact]
public void Select_Match_CaseInsensitiveMatch_MatchesOnEmptyString()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "area", null },
{ "controller", "Home" },
{ "action", "Index" }
},
}
};
var table = CreateTableWithActionDescriptors(actions);
// Example: In conventional route, one could set non-inline defaults
// new { area = "", controller = "Home", action = "Index" }
var values = new RouteValueDictionary(new { area = "", controller = "HoMe", action = "InDeX", });
// Act
var matches = table.Select(values);
// Assert
var action = Assert.Single(matches);
Assert.Same(actions[0], action);
}
[Fact]
public void Select_Match_MatchesOnNull()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "area", null },
{ "controller", "Home" },
{ "action", "Index" }
},
}
};
var table = CreateTableWithActionDescriptors(actions);
// Example: In conventional route, one could set non-inline defaults
// new { area = (string)null, controller = "Foo", action = "Index" }
var values = new RouteValueDictionary(new { area = (string)null, controller = "Home", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
var action = Assert.Single(matches);
Assert.Same(actions[0], action);
}
[Fact]
public void Select_Match_ActionDescriptorWithEmptyRouteValues_MatchesOnEmptyString()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "foo", "" },
{ "controller", "Home" },
{ "action", "Index" }
},
}
};
var table = CreateTableWithActionDescriptors(actions);
var values = new RouteValueDictionary(new { foo = "", controller = "Home", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
var action = Assert.Single(matches);
Assert.Same(actions[0], action);
}
[Fact]
public void Select_Match_ActionDescriptorWithEmptyRouteValues_MatchesOnNull()
{
var actions = new ActionDescriptor[]
{
new ActionDescriptor()
{
DisplayName = "A1",
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "foo", "" },
{ "controller", "Home" },
{ "action", "Index" }
},
}
};
var table = CreateTableWithActionDescriptors(actions);
var values = new RouteValueDictionary(new { foo = (string)null, controller = "Home", action = "Index", });
// Act
var matches = table.Select(values);
// Assert
var action = Assert.Single(matches);
Assert.Same(actions[0], action);
}
private static ActionSelectionTable<ActionDescriptor> CreateTableWithActionDescriptors(IReadOnlyList<ActionDescriptor> actions)
{
return ActionSelectionTable<ActionDescriptor>.Create(new ActionDescriptorCollection(actions, 0));
}
private static ActionSelectionTable<RouteEndpoint> CreateTableWithEndpoints(IReadOnlyList<ActionDescriptor> actions)
{
var endpoints = actions.Select(a =>
{
var metadata = new List<object>(a.EndpointMetadata ?? Array.Empty<object>());
metadata.Add(a);
return new RouteEndpoint(
requestDelegate: context => Task.CompletedTask,
routePattern: RoutePatternFactory.Parse("/", defaults: a.RouteValues, parameterPolicies: null, requiredValues: a.RouteValues),
order: 0,
metadata: new EndpointMetadataCollection(metadata),
a.DisplayName);
});
return ActionSelectionTable<ActionDescriptor>.Create(endpoints);
}
}
}