Initial endpoint constraints functionality (#548)

This commit is contained in:
James Newton-King 2018-06-12 09:43:17 +12:00 committed by GitHub
parent 08f12f2bfd
commit 84bc8351c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1242 additions and 24 deletions

View File

@ -3,6 +3,7 @@
using System;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.EndpointConstraints;
using Microsoft.AspNetCore.Routing.Matchers;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
@ -33,6 +34,16 @@ namespace Microsoft.Extensions.DependencyInjection
//
services.TryAddSingleton<MatcherFactory, TreeMatcherFactory>();
//
// Endpoint Selection
//
services.TryAddSingleton<EndpointSelector>();
services.TryAddSingleton<EndpointConstraintCache>();
// Will be cached by the EndpointSelector
services.TryAddEnumerable(
ServiceDescriptor.Transient<IEndpointConstraintProvider, DefaultEndpointConstraintProvider>());
return services;
}

View File

@ -15,14 +15,14 @@ namespace Microsoft.AspNetCore.Routing
{
private readonly MatcherFactory _matcherFactory;
private readonly ILogger _logger;
private readonly IOptions<DispatcherOptions> _options;
private readonly CompositeEndpointDataSource _endpointDataSource;
private readonly RequestDelegate _next;
private Task<Matcher> _initializationTask;
public DispatcherMiddleware(
MatcherFactory matcherFactory,
IOptions<DispatcherOptions> options,
CompositeEndpointDataSource endpointDataSource,
ILogger<DispatcherMiddleware> logger,
RequestDelegate next)
{
@ -31,9 +31,9 @@ namespace Microsoft.AspNetCore.Routing
throw new ArgumentNullException(nameof(matcherFactory));
}
if (options == null)
if (endpointDataSource == null)
{
throw new ArgumentNullException(nameof(options));
throw new ArgumentNullException(nameof(endpointDataSource));
}
if (logger == null)
@ -47,7 +47,7 @@ namespace Microsoft.AspNetCore.Routing
}
_matcherFactory = matcherFactory;
_options = options;
_endpointDataSource = endpointDataSource;
_logger = logger;
_next = next;
}
@ -94,8 +94,7 @@ namespace Microsoft.AspNetCore.Routing
null) == null)
{
// This thread won the race, do the initialization.
var dataSource = new CompositeEndpointDataSource(_options.Value.DataSources);
var matcher = _matcherFactory.CreateMatcher(dataSource);
var matcher = _matcherFactory.CreateMatcher(_endpointDataSource);
initializationTask.SetResult(matcher);
}

View File

@ -0,0 +1,193 @@
// 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 Microsoft.AspNetCore.Http;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace Microsoft.AspNetCore.Routing.EndpointConstraints
{
internal class EndpointConstraintCache
{
private readonly CompositeEndpointDataSource _dataSource;
private readonly IEndpointConstraintProvider[] _endpointConstraintProviders;
private volatile InnerCache _currentCache;
public EndpointConstraintCache(
CompositeEndpointDataSource dataSource,
IEnumerable<IEndpointConstraintProvider> endpointConstraintProviders)
{
_dataSource = dataSource;
_endpointConstraintProviders = endpointConstraintProviders.OrderBy(item => item.Order).ToArray();
}
private InnerCache CurrentCache
{
get
{
var current = _currentCache;
var endpointDescriptors = _dataSource.Endpoints;
if (current == null)
{
current = new InnerCache();
_currentCache = current;
}
return current;
}
}
public IReadOnlyList<IEndpointConstraint> GetEndpointConstraints(HttpContext httpContext, Endpoint endpoint)
{
var cache = CurrentCache;
if (cache.Entries.TryGetValue(endpoint, out var entry))
{
return GetEndpointConstraintsFromEntry(entry, httpContext, endpoint);
}
if (endpoint.Metadata == null || endpoint.Metadata.Count == 0)
{
return null;
}
var items = endpoint.Metadata
.OfType<IEndpointConstraintMetadata>()
.Select(m => new EndpointConstraintItem(m))
.ToList();
ExecuteProviders(httpContext, endpoint, items);
var endpointConstraints = ExtractEndpointConstraints(items);
var allEndpointConstraintsCached = true;
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
if (!item.IsReusable)
{
item.Constraint = null;
allEndpointConstraintsCached = false;
}
}
if (allEndpointConstraintsCached)
{
entry = new CacheEntry(endpointConstraints);
}
else
{
entry = new CacheEntry(items);
}
cache.Entries.TryAdd(endpoint, entry);
return endpointConstraints;
}
private IReadOnlyList<IEndpointConstraint> GetEndpointConstraintsFromEntry(CacheEntry entry, HttpContext httpContext, Endpoint endpoint)
{
Debug.Assert(entry.EndpointConstraints != null || entry.Items != null);
if (entry.EndpointConstraints != null)
{
return entry.EndpointConstraints;
}
var items = new List<EndpointConstraintItem>(entry.Items.Count);
for (var i = 0; i < entry.Items.Count; i++)
{
var item = entry.Items[i];
if (item.IsReusable)
{
items.Add(item);
}
else
{
items.Add(new EndpointConstraintItem(item.Metadata));
}
}
ExecuteProviders(httpContext, endpoint, items);
return ExtractEndpointConstraints(items);
}
private void ExecuteProviders(HttpContext httpContext, Endpoint endpoint, List<EndpointConstraintItem> items)
{
var context = new EndpointConstraintProviderContext(httpContext, endpoint, items);
for (var i = 0; i < _endpointConstraintProviders.Length; i++)
{
_endpointConstraintProviders[i].OnProvidersExecuting(context);
}
for (var i = _endpointConstraintProviders.Length - 1; i >= 0; i--)
{
_endpointConstraintProviders[i].OnProvidersExecuted(context);
}
}
private IReadOnlyList<IEndpointConstraint> ExtractEndpointConstraints(List<EndpointConstraintItem> items)
{
var count = 0;
for (var i = 0; i < items.Count; i++)
{
if (items[i].Constraint != null)
{
count++;
}
}
if (count == 0)
{
return null;
}
var endpointConstraints = new IEndpointConstraint[count];
var endpointConstraintIndex = 0;
for (int i = 0; i < items.Count; i++)
{
var endpointConstraint = items[i].Constraint;
if (endpointConstraint != null)
{
endpointConstraints[endpointConstraintIndex++] = endpointConstraint;
}
}
return endpointConstraints;
}
private class InnerCache
{
public InnerCache()
{
}
public ConcurrentDictionary<Endpoint, CacheEntry> Entries { get; } =
new ConcurrentDictionary<Endpoint, CacheEntry>();
}
private struct CacheEntry
{
public CacheEntry(IReadOnlyList<IEndpointConstraint> endpointConstraints)
{
EndpointConstraints = endpointConstraints;
Items = null;
}
public CacheEntry(List<EndpointConstraintItem> items)
{
Items = items;
EndpointConstraints = null;
}
public IReadOnlyList<IEndpointConstraint> EndpointConstraints { get; }
public List<EndpointConstraintItem> Items { get; }
}
}
}

View File

@ -0,0 +1,225 @@
// 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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing.Matchers;
using Microsoft.Extensions.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
namespace Microsoft.AspNetCore.Routing.EndpointConstraints
{
internal class EndpointSelector
{
private static readonly IReadOnlyList<Endpoint> EmptyEndpoints = Array.Empty<Endpoint>();
private readonly CompositeEndpointDataSource _dataSource;
private readonly EndpointConstraintCache _endpointConstraintCache;
private readonly ILogger _logger;
public EndpointSelector(
CompositeEndpointDataSource dataSource,
EndpointConstraintCache endpointConstraintCache,
ILoggerFactory loggerFactory)
{
_dataSource = dataSource;
_logger = loggerFactory.CreateLogger<EndpointSelector>();
_endpointConstraintCache = endpointConstraintCache;
}
public Endpoint SelectBestCandidate(HttpContext context, IReadOnlyList<Endpoint> candidates)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (candidates == null)
{
throw new ArgumentNullException(nameof(candidates));
}
var finalMatches = EvaluateEndpointConstraints(context, candidates);
if (finalMatches == null || finalMatches.Count == 0)
{
return null;
}
else if (finalMatches.Count == 1)
{
var selectedEndpoint = finalMatches[0];
return selectedEndpoint;
}
else
{
var endpointNames = string.Join(
Environment.NewLine,
finalMatches.Select(a => a.DisplayName));
Log.MatchAmbiguous(_logger, context, finalMatches);
var message = Resources.FormatAmbiguousEndpoints(
Environment.NewLine,
string.Join(Environment.NewLine, endpointNames));
throw new AmbiguousMatchException(message);
}
}
private IReadOnlyList<Endpoint> EvaluateEndpointConstraints(
HttpContext context,
IReadOnlyList<Endpoint> endpoints)
{
var candidates = new List<EndpointSelectorCandidate>();
// Perf: Avoid allocations
for (var i = 0; i < endpoints.Count; i++)
{
var endpoint = endpoints[i];
var constraints = _endpointConstraintCache.GetEndpointConstraints(context, endpoint);
candidates.Add(new EndpointSelectorCandidate(endpoint, constraints));
}
var matches = EvaluateEndpointConstraintsCore(context, candidates, startingOrder: null);
List<Endpoint> results = null;
if (matches != null)
{
results = new List<Endpoint>(matches.Count);
// Perf: Avoid allocations
for (var i = 0; i < matches.Count; i++)
{
var candidate = matches[i];
results.Add(candidate.Endpoint);
}
}
return results;
}
private IReadOnlyList<EndpointSelectorCandidate> EvaluateEndpointConstraintsCore(
HttpContext context,
IReadOnlyList<EndpointSelectorCandidate> candidates,
int? startingOrder)
{
// Find the next group of constraints to process. This will be the lowest value of
// order that is higher than startingOrder.
int? order = null;
// Perf: Avoid allocations
for (var i = 0; i < candidates.Count; i++)
{
var candidate = candidates[i];
if (candidate.Constraints != null)
{
for (var j = 0; j < candidate.Constraints.Count; j++)
{
var constraint = candidate.Constraints[j];
if ((startingOrder == null || constraint.Order > startingOrder) &&
(order == null || constraint.Order < order))
{
order = constraint.Order;
}
}
}
}
// If we don't find a next then there's nothing left to do.
if (order == null)
{
return candidates;
}
// Since we have a constraint to process, bisect the set of endpoints into those with and without a
// constraint for the current order.
var endpointsWithConstraint = new List<EndpointSelectorCandidate>();
var endpointsWithoutConstraint = new List<EndpointSelectorCandidate>();
var constraintContext = new EndpointConstraintContext();
constraintContext.Candidates = candidates;
constraintContext.HttpContext = context;
// Perf: Avoid allocations
for (var i = 0; i < candidates.Count; i++)
{
var candidate = candidates[i];
var isMatch = true;
var foundMatchingConstraint = false;
if (candidate.Constraints != null)
{
constraintContext.CurrentCandidate = candidate;
for (var j = 0; j < candidate.Constraints.Count; j++)
{
var constraint = candidate.Constraints[j];
if (constraint.Order == order)
{
foundMatchingConstraint = true;
if (!constraint.Accept(constraintContext))
{
isMatch = false;
//_logger.ConstraintMismatch(
// candidate.Endpoint.DisplayName,
// candidate.Endpoint.Id,
// constraint);
break;
}
}
}
}
if (isMatch && foundMatchingConstraint)
{
endpointsWithConstraint.Add(candidate);
}
else if (isMatch)
{
endpointsWithoutConstraint.Add(candidate);
}
}
// If we have matches with constraints, those are better so try to keep processing those
if (endpointsWithConstraint.Count > 0)
{
var matches = EvaluateEndpointConstraintsCore(context, endpointsWithConstraint, order);
if (matches?.Count > 0)
{
return matches;
}
}
// If the set of matches with constraints can't work, then process the set without constraints.
if (endpointsWithoutConstraint.Count == 0)
{
return null;
}
else
{
return EvaluateEndpointConstraintsCore(context, endpointsWithoutConstraint, order);
}
}
private static class Log
{
private static readonly Action<ILogger, PathString, IEnumerable<string>, Exception> _matchAmbiguous = LoggerMessage.Define<PathString, IEnumerable<string>>(
LogLevel.Error,
new EventId(1, "MatchAmbiguous"),
"Request matched multiple endpoints for request path '{Path}'. Matching endpoints: {AmbiguousEndpoints}");
public static void MatchAmbiguous(ILogger logger, HttpContext httpContext, IEnumerable<Endpoint> endpoints)
{
if (logger.IsEnabled(LogLevel.Error))
{
_matchAmbiguous(logger, httpContext.Request.Path, endpoints.Select(e => e.DisplayName), null);
}
}
}
}
}

View File

@ -0,0 +1,70 @@
// 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.Collections.ObjectModel;
namespace Microsoft.AspNetCore.Routing.EndpointConstraints
{
public class HttpMethodEndpointConstraint : IEndpointConstraint
{
public static readonly int HttpMethodConstraintOrder = 100;
private readonly IReadOnlyList<string> _httpMethods;
// Empty collection means any method will be accepted.
public HttpMethodEndpointConstraint(IEnumerable<string> httpMethods)
{
if (httpMethods == null)
{
throw new ArgumentNullException(nameof(httpMethods));
}
var methods = new List<string>();
foreach (var method in httpMethods)
{
if (string.IsNullOrEmpty(method))
{
throw new ArgumentException("httpMethod cannot be null or empty");
}
methods.Add(method);
}
_httpMethods = new ReadOnlyCollection<string>(methods);
}
public IEnumerable<string> HttpMethods => _httpMethods;
public int Order => HttpMethodConstraintOrder;
public virtual bool Accept(EndpointConstraintContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (_httpMethods.Count == 0)
{
return true;
}
var request = context.HttpContext.Request;
var method = request.Method;
for (var i = 0; i < _httpMethods.Count; i++)
{
var supportedMethod = _httpMethods[i];
if (string.Equals(supportedMethod, method, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
}

View File

@ -0,0 +1,165 @@
// 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 Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Text;
namespace Microsoft.AspNetCore.Routing.EndpointConstraints
{
public class EndpointConstraintContext
{
public IReadOnlyList<EndpointSelectorCandidate> Candidates { get; set; }
public EndpointSelectorCandidate CurrentCandidate { get; set; }
public HttpContext HttpContext { get; set; }
}
public interface IEndpointConstraint : IEndpointConstraintMetadata
{
int Order { get; }
bool Accept(EndpointConstraintContext context);
}
public interface IEndpointConstraintMetadata
{
}
public struct EndpointSelectorCandidate
{
public EndpointSelectorCandidate(Endpoint endpoint, IReadOnlyList<IEndpointConstraint> constraints)
{
if (endpoint == null)
{
throw new ArgumentNullException(nameof(endpoint));
}
Endpoint = endpoint;
Constraints = constraints;
}
public Endpoint Endpoint { get; }
public IReadOnlyList<IEndpointConstraint> Constraints { get; }
}
public class EndpointConstraintItem
{
public EndpointConstraintItem(IEndpointConstraintMetadata metadata)
{
if (metadata == null)
{
throw new ArgumentNullException(nameof(metadata));
}
Metadata = metadata;
}
public IEndpointConstraint Constraint { get; set; }
public IEndpointConstraintMetadata Metadata { get; }
public bool IsReusable { get; set; }
}
public interface IEndpointConstraintProvider
{
int Order { get; }
void OnProvidersExecuting(EndpointConstraintProviderContext context);
void OnProvidersExecuted(EndpointConstraintProviderContext context);
}
public class EndpointConstraintProviderContext
{
public EndpointConstraintProviderContext(
HttpContext context,
Endpoint endpoint,
IList<EndpointConstraintItem> items)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (endpoint == null)
{
throw new ArgumentNullException(nameof(endpoint));
}
if (items == null)
{
throw new ArgumentNullException(nameof(items));
}
HttpContext = context;
Endpoint = endpoint;
Results = items;
}
public HttpContext HttpContext { get; }
public Endpoint Endpoint { get; }
public IList<EndpointConstraintItem> Results { get; }
}
public class DefaultEndpointConstraintProvider : IEndpointConstraintProvider
{
/// <inheritdoc />
public int Order => -1000;
/// <inheritdoc />
public void OnProvidersExecuting(EndpointConstraintProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
for (var i = 0; i < context.Results.Count; i++)
{
ProvideConstraint(context.Results[i], context.HttpContext.RequestServices);
}
}
/// <inheritdoc />
public void OnProvidersExecuted(EndpointConstraintProviderContext context)
{
}
private void ProvideConstraint(EndpointConstraintItem item, IServiceProvider services)
{
// Don't overwrite anything that was done by a previous provider.
if (item.Constraint != null)
{
return;
}
if (item.Metadata is IEndpointConstraint constraint)
{
item.Constraint = constraint;
item.IsReusable = true;
return;
}
if (item.Metadata is IEndpointConstraintFactory factory)
{
item.Constraint = factory.CreateInstance(services);
item.IsReusable = factory.IsReusable;
return;
}
}
}
public interface IEndpointConstraintFactory : IEndpointConstraintMetadata
{
bool IsReusable { get; }
IEndpointConstraint CreateInstance(IServiceProvider services);
}
}

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing.EndpointConstraints;
using Microsoft.AspNetCore.Routing.Internal;
using Microsoft.AspNetCore.Routing.Template;
using Microsoft.AspNetCore.Routing.Tree;
@ -18,12 +19,14 @@ namespace Microsoft.AspNetCore.Routing.Matchers
{
private readonly IInlineConstraintResolver _constraintFactory;
private readonly ILogger _logger;
private readonly EndpointSelector _endpointSelector;
private readonly DataSourceDependantCache<UrlMatchingTree[]> _cache;
public TreeMatcher(
IInlineConstraintResolver constraintFactory,
ILogger logger,
EndpointDataSource dataSource)
EndpointDataSource dataSource,
EndpointSelector endpointSelector)
{
if (constraintFactory == null)
{
@ -42,6 +45,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers
_constraintFactory = constraintFactory;
_logger = logger;
_endpointSelector = endpointSelector;
_cache = new DataSourceDependantCache<UrlMatchingTree[]>(dataSource, CreateTrees);
_cache.EnsureInitialized();
}
@ -137,6 +141,8 @@ namespace Microsoft.AspNetCore.Routing.Matchers
private Task SelectEndpointAsync(HttpContext httpContext, IEndpointFeature feature, IReadOnlyList<MatcherEndpoint> endpoints)
{
var bestEndpoint = _endpointSelector.SelectBestCandidate(httpContext, endpoints);
// REVIEW: Note that this code doesn't do anything significant now. This will eventually incorporate something like IActionConstraint
switch (endpoints.Count)
{

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNetCore.Routing.EndpointConstraints;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Routing.Matchers
@ -10,8 +11,12 @@ namespace Microsoft.AspNetCore.Routing.Matchers
{
private readonly IInlineConstraintResolver _constraintFactory;
private readonly ILogger<TreeMatcher> _logger;
private readonly EndpointSelector _endpointSelector;
public TreeMatcherFactory(IInlineConstraintResolver constraintFactory, ILogger<TreeMatcher> logger)
public TreeMatcherFactory(
IInlineConstraintResolver constraintFactory,
ILogger<TreeMatcher> logger,
EndpointSelector endpointSelector)
{
if (constraintFactory == null)
{
@ -23,8 +28,14 @@ namespace Microsoft.AspNetCore.Routing.Matchers
throw new ArgumentNullException(nameof(logger));
}
if (endpointSelector == null)
{
throw new ArgumentNullException(nameof(endpointSelector));
}
_constraintFactory = constraintFactory;
_logger = logger;
_endpointSelector = endpointSelector;
}
public override Matcher CreateMatcher(EndpointDataSource dataSource)
@ -34,7 +45,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers
throw new ArgumentNullException(nameof(dataSource));
}
return new TreeMatcher(_constraintFactory, _logger, dataSource);
return new TreeMatcher(_constraintFactory, _logger, dataSource, _endpointSelector);
}
}
}

View File

@ -24,12 +24,7 @@ namespace Microsoft.AspNetCore.Routing
var httpContext = new DefaultHttpContext();
httpContext.RequestServices = new TestServiceProvider();
RequestDelegate next = (c) => Task.FromResult<object>(null);
var logger = new Logger<DispatcherMiddleware>(NullLoggerFactory.Instance);
var options = Options.Create(new DispatcherOptions());
var matcherFactory = new TestMatcherFactory(false);
var middleware = new DispatcherMiddleware(matcherFactory, options, logger, next);
var middleware = CreateMiddleware();
// Act
await middleware.Invoke(httpContext);
@ -53,12 +48,8 @@ namespace Microsoft.AspNetCore.Routing
var httpContext = new DefaultHttpContext();
httpContext.RequestServices = new TestServiceProvider();
RequestDelegate next = (c) => Task.FromResult<object>(null);
var logger = new Logger<DispatcherMiddleware>(loggerFactory);
var options = Options.Create(new DispatcherOptions());
var matcherFactory = new TestMatcherFactory(true);
var middleware = new DispatcherMiddleware(matcherFactory, options, logger, next);
var middleware = CreateMiddleware(logger);
// Act
await middleware.Invoke(httpContext);
@ -68,5 +59,22 @@ namespace Microsoft.AspNetCore.Routing
var write = Assert.Single(sink.Writes);
Assert.Equal(expectedMessage, write.State?.ToString());
}
private DispatcherMiddleware CreateMiddleware(Logger<DispatcherMiddleware> logger = null)
{
RequestDelegate next = (c) => Task.FromResult<object>(null);
logger = logger ?? new Logger<DispatcherMiddleware>(NullLoggerFactory.Instance);
var options = Options.Create(new DispatcherOptions());
var matcherFactory = new TestMatcherFactory(true);
var middleware = new DispatcherMiddleware(
matcherFactory,
new CompositeEndpointDataSource(Array.Empty<EndpointDataSource>()),
logger,
next);
return middleware;
}
}
}

View File

@ -0,0 +1,438 @@
// 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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing.Matchers;
using Microsoft.AspNetCore.Routing.TestObjects;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;
namespace Microsoft.AspNetCore.Routing.EndpointConstraints
{
public class EndpointSelectorTests
{
[Fact]
public void SelectBestCandidate_MultipleEndpoints_BestMatchSelected()
{
// Arrange
var defaultEndpoint = new TestEndpoint(
EndpointMetadataCollection.Empty,
"No constraint endpoint");
var postEndpoint = new TestEndpoint(
new EndpointMetadataCollection(new object[] { new HttpMethodEndpointConstraint(new[] { "POST" }) }),
"POST constraint endpoint");
var endpoints = new Endpoint[]
{
defaultEndpoint,
postEndpoint
};
var endpointSelector = CreateSelector(endpoints);
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "POST";
// Act
var bestCandidateEndpoint = endpointSelector.SelectBestCandidate(httpContext, endpoints);
// Assert
Assert.NotNull(postEndpoint);
}
[Fact]
public void SelectBestCandidate_MultipleEndpoints_AmbiguousMatchExceptionThrown()
{
// Arrange
var expectedMessage =
"The request matched multiple endpoints. Matches: " + Environment.NewLine +
Environment.NewLine +
"Ambiguous1" + Environment.NewLine +
"Ambiguous2";
var defaultEndpoint1 = new TestEndpoint(
EndpointMetadataCollection.Empty,
"Ambiguous1");
var defaultEndpoint2 = new TestEndpoint(
EndpointMetadataCollection.Empty,
"Ambiguous2");
var endpoints = new Endpoint[]
{
defaultEndpoint1,
defaultEndpoint2
};
var endpointSelector = CreateSelector(endpoints);
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "POST";
// Act
var ex = Assert.ThrowsAny<AmbiguousMatchException>(() =>
{
endpointSelector.SelectBestCandidate(httpContext, endpoints);
});
// Assert
Assert.Equal(expectedMessage, ex.Message);
}
[Fact]
public void SelectBestCandidate_AmbiguousEndpoints_LogIsCorrect()
{
// Arrange
var sink = new TestSink();
var loggerFactory = new TestLoggerFactory(sink, enabled: true);
var actions = new Endpoint[]
{
new TestEndpoint(EndpointMetadataCollection.Empty, "A1"),
new TestEndpoint(EndpointMetadataCollection.Empty, "A2"),
};
var selector = CreateSelector(actions, loggerFactory);
var httpContext = CreateHttpContext("POST");
var actionNames = string.Join(", ", actions.Select(action => action.DisplayName));
var expectedMessage = $"Request matched multiple endpoints for request path '/test'. Matching endpoints: {actionNames}";
// Act
Assert.Throws<AmbiguousMatchException>(() => { selector.SelectBestCandidate(httpContext, actions); });
// Assert
Assert.Empty(sink.Scopes);
var write = Assert.Single(sink.Writes);
Assert.Equal(expectedMessage, write.State?.ToString());
}
[Fact]
public void SelectBestCandidate_PrefersEndpointWithConstraints()
{
// Arrange
var actionWithConstraints = new TestEndpoint(
new EndpointMetadataCollection(new[] { new HttpMethodEndpointConstraint(new string[] { "POST" }) }),
"Has constraint");
var actionWithoutConstraints = new TestEndpoint(EndpointMetadataCollection.Empty, "No constraint");
var actions = new Endpoint[] { actionWithConstraints, actionWithoutConstraints };
var selector = CreateSelector(actions);
var context = CreateHttpContext("POST");
// Act
var action = selector.SelectBestCandidate(context, actions);
// Assert
Assert.Same(action, actionWithConstraints);
}
[Fact]
public void SelectBestCandidate_ConstraintsRejectAll()
{
// Arrange
var action1 = new TestEndpoint(new EndpointMetadataCollection(new[] { new BooleanConstraint() { Pass = false, } }), "action1");
var action2 = new TestEndpoint(new EndpointMetadataCollection(new[] { new BooleanConstraint() { Pass = false, } }), "action2");
var actions = new Endpoint[] { action1, action2 };
var selector = CreateSelector(actions);
var context = CreateHttpContext("POST");
// Act
var action = selector.SelectBestCandidate(context, actions);
// Assert
Assert.Null(action);
}
[Fact]
public void SelectBestCandidate_ConstraintsRejectAll_DifferentStages()
{
// Arrange
var action1 = new TestEndpoint(new EndpointMetadataCollection(new[]
{
new BooleanConstraint() { Pass = false, Order = 0 },
new BooleanConstraint() { Pass = true, Order = 1 },
}), "action1");
var action2 = new TestEndpoint(new EndpointMetadataCollection(new[]
{
new BooleanConstraint() { Pass = true, Order = 0 },
new BooleanConstraint() { Pass = false, Order = 1 },
}), "action2");
var actions = new Endpoint[] { action1, action2 };
var selector = CreateSelector(actions);
var context = CreateHttpContext("POST");
// Act
var action = selector.SelectBestCandidate(context, actions);
// Assert
Assert.Null(action);
}
[Fact]
public void SelectBestCandidate_EndpointConstraintFactory()
{
// Arrange
var actionWithConstraints = new TestEndpoint(new EndpointMetadataCollection(new[]
{
new ConstraintFactory()
{
Constraint = new BooleanConstraint() { Pass = true },
},
}), "actionWithConstraints");
var actionWithoutConstraints = new TestEndpoint(EndpointMetadataCollection.Empty, "actionWithoutConstraints");
var actions = new Endpoint[] { actionWithConstraints, actionWithoutConstraints };
var selector = CreateSelector(actions);
var context = CreateHttpContext("POST");
// Act
var action = selector.SelectBestCandidate(context, actions);
// Assert
Assert.Same(action, actionWithConstraints);
}
[Fact]
public void SelectBestCandidate_EndpointConstraintFactory_ReturnsNull()
{
// Arrange
var nullConstraint = new TestEndpoint(new EndpointMetadataCollection(new[]
{
new ConstraintFactory(),
}), "nullConstraint");
var actions = new Endpoint[] { nullConstraint };
var selector = CreateSelector(actions);
var context = CreateHttpContext("POST");
// Act
var action = selector.SelectBestCandidate(context, actions);
// Assert
Assert.Same(action, nullConstraint);
}
// There's a custom constraint provider registered that only understands BooleanConstraintMarker
[Fact]
public void SelectBestCandidate_CustomProvider()
{
// Arrange
var actionWithConstraints = new TestEndpoint(new EndpointMetadataCollection(new[]
{
new BooleanConstraintMarker() { Pass = true },
}), "actionWithConstraints");
var actionWithoutConstraints = new TestEndpoint(EndpointMetadataCollection.Empty, "actionWithoutConstraints");
var actions = new Endpoint[] { actionWithConstraints, actionWithoutConstraints, };
var selector = CreateSelector(actions);
var context = CreateHttpContext("POST");
// Act
var action = selector.SelectBestCandidate(context, actions);
// Assert
Assert.Same(action, actionWithConstraints);
}
// Due to ordering of stages, the first action will be better.
[Fact]
public void SelectBestCandidate_ConstraintsInOrder()
{
// Arrange
var best = new TestEndpoint(new EndpointMetadataCollection(new[]
{
new BooleanConstraint() { Pass = true, Order = 0, },
}), "best");
var worst = new TestEndpoint(new EndpointMetadataCollection(new[]
{
new BooleanConstraint() { Pass = true, Order = 1, },
}), "worst");
var actions = new Endpoint[] { best, worst };
var selector = CreateSelector(actions);
var context = CreateHttpContext("POST");
// Act
var action = selector.SelectBestCandidate(context, actions);
// Assert
Assert.Same(action, best);
}
// Due to ordering of stages, the first action will be better.
[Fact]
public void SelectBestCandidate_ConstraintsInOrder_MultipleStages()
{
// Arrange
var best = new TestEndpoint(new EndpointMetadataCollection(new[]
{
new BooleanConstraint() { Pass = true, Order = 0, },
new BooleanConstraint() { Pass = true, Order = 1, },
new BooleanConstraint() { Pass = true, Order = 2, },
}), "best");
var worst = new TestEndpoint(new EndpointMetadataCollection(new[]
{
new BooleanConstraint() { Pass = true, Order = 0, },
new BooleanConstraint() { Pass = true, Order = 1, },
new BooleanConstraint() { Pass = true, Order = 3, },
}), "worst");
var actions = new Endpoint[] { best, worst };
var selector = CreateSelector(actions);
var context = CreateHttpContext("POST");
// Act
var action = selector.SelectBestCandidate(context, actions);
// Assert
Assert.Same(action, best);
}
[Fact]
public void SelectBestCandidate_Fallback_ToEndpointWithoutConstraints()
{
// Arrange
var nomatch1 = new TestEndpoint(new EndpointMetadataCollection(new[]
{
new BooleanConstraint() { Pass = true, Order = 0, },
new BooleanConstraint() { Pass = true, Order = 1, },
new BooleanConstraint() { Pass = false, Order = 2, },
}), "nomatch1");
var nomatch2 = new TestEndpoint(new EndpointMetadataCollection(new[]
{
new BooleanConstraint() { Pass = true, Order = 0, },
new BooleanConstraint() { Pass = true, Order = 1, },
new BooleanConstraint() { Pass = false, Order = 3, },
}), "nomatch2");
var best = new TestEndpoint(EndpointMetadataCollection.Empty, "best");
var actions = new Endpoint[] { best, nomatch1, nomatch2 };
var selector = CreateSelector(actions);
var context = CreateHttpContext("POST");
// Act
var action = selector.SelectBestCandidate(context, actions);
// Assert
Assert.Same(action, best);
}
private static EndpointSelector CreateSelector(IReadOnlyList<Endpoint> actions, ILoggerFactory loggerFactory = null)
{
loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
var endpointDataSource = new CompositeEndpointDataSource(new[] { new DefaultEndpointDataSource(actions) });
var actionConstraintProviders = new IEndpointConstraintProvider[] {
new DefaultEndpointConstraintProvider(),
new BooleanConstraintProvider(),
};
return new EndpointSelector(
endpointDataSource,
GetEndpointConstraintCache(actionConstraintProviders),
loggerFactory);
}
private static HttpContext CreateHttpContext(string httpMethod)
{
var serviceProvider = new ServiceCollection().BuildServiceProvider();
var httpContext = new Mock<HttpContext>(MockBehavior.Strict);
var request = new Mock<HttpRequest>(MockBehavior.Strict);
request.SetupGet(r => r.Method).Returns(httpMethod);
request.SetupGet(r => r.Path).Returns(new PathString("/test"));
request.SetupGet(r => r.Headers).Returns(new HeaderDictionary());
httpContext.SetupGet(c => c.Request).Returns(request.Object);
httpContext.SetupGet(c => c.RequestServices).Returns(serviceProvider);
return httpContext.Object;
}
private static EndpointConstraintCache GetEndpointConstraintCache(IEndpointConstraintProvider[] actionConstraintProviders = null)
{
return new EndpointConstraintCache(
new CompositeEndpointDataSource(Array.Empty<EndpointDataSource>()),
actionConstraintProviders.AsEnumerable() ?? new List<IEndpointConstraintProvider>());
}
private class BooleanConstraint : IEndpointConstraint
{
public bool Pass { get; set; }
public int Order { get; set; }
public bool Accept(EndpointConstraintContext context)
{
return Pass;
}
}
private class ConstraintFactory : IEndpointConstraintFactory
{
public IEndpointConstraint Constraint { get; set; }
public bool IsReusable => true;
public IEndpointConstraint CreateInstance(IServiceProvider services)
{
return Constraint;
}
}
private class BooleanConstraintMarker : IEndpointConstraintMetadata
{
public bool Pass { get; set; }
}
private class BooleanConstraintProvider : IEndpointConstraintProvider
{
public int Order { get; set; }
public void OnProvidersExecuting(EndpointConstraintProviderContext context)
{
foreach (var item in context.Results)
{
if (item.Metadata is BooleanConstraintMarker marker)
{
Assert.Null(item.Constraint);
item.Constraint = new BooleanConstraint() { Pass = marker.Pass };
}
}
}
public void OnProvidersExecuted(EndpointConstraintProviderContext context)
{
}
}
}
}

View File

@ -0,0 +1,87 @@
// 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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing.EndpointConstraints;
using Microsoft.AspNetCore.Routing.TestObjects;
using Microsoft.Extensions.Primitives;
using System;
using System.Collections.Generic;
using System.Text;
using Xunit;
namespace Microsoft.AspNetCore.Routing.Internal
{
public class HttpMethodEndpointConstraintTest
{
public static TheoryData AcceptCaseInsensitiveData =
new TheoryData<IEnumerable<string>, string>
{
{ new string[] { "get", "Get", "GET", "GEt"}, "gEt" },
{ new string[] { "POST", "PoSt", "GEt"}, "GET" },
{ new string[] { "get" }, "get" },
{ new string[] { "post" }, "POST" },
{ new string[] { "gEt" }, "get" },
{ new string[] { "get", "PoST" }, "pOSt" }
};
[Theory]
[MemberData(nameof(AcceptCaseInsensitiveData))]
public void HttpMethodEndpointConstraint_IgnoresPreflightRequests(IEnumerable<string> httpMethods, string accessControlMethod)
{
// Arrange
var constraint = new HttpMethodEndpointConstraint(httpMethods);
var context = CreateEndpointConstraintContext(constraint);
context.HttpContext = CreateHttpContext("oPtIoNs", accessControlMethod);
// Act
var result = constraint.Accept(context);
// Assert
Assert.False(result, "Request should have been rejected.");
}
[Theory]
[MemberData(nameof(AcceptCaseInsensitiveData))]
public void HttpMethodEndpointConstraint_Accept_CaseInsensitive(IEnumerable<string> httpMethods, string expectedMethod)
{
// Arrange
var constraint = new HttpMethodEndpointConstraint(httpMethods);
var context = CreateEndpointConstraintContext(constraint);
context.HttpContext = CreateHttpContext(expectedMethod);
// Act
var result = constraint.Accept(context);
// Assert
Assert.True(result, "Request should have been accepted.");
}
private static EndpointConstraintContext CreateEndpointConstraintContext(HttpMethodEndpointConstraint constraint)
{
var context = new EndpointConstraintContext();
var endpointSelectorCandidate = new EndpointSelectorCandidate(new TestEndpoint(EndpointMetadataCollection.Empty, string.Empty), new List<IEndpointConstraint> { constraint });
context.Candidates = new List<EndpointSelectorCandidate> { endpointSelectorCandidate };
context.CurrentCandidate = context.Candidates[0];
return context;
}
private static HttpContext CreateHttpContext(string requestedMethod, string accessControlMethod = null)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = requestedMethod;
if (accessControlMethod != null)
{
httpContext.Request.Headers.Add("Origin", StringValues.Empty);
httpContext.Request.Headers.Add("Access-Control-Request-Method", accessControlMethod);
}
return httpContext;
}
}
}

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing.EndpointConstraints;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System;
@ -21,16 +22,20 @@ namespace Microsoft.AspNetCore.Routing.Matchers
private TreeMatcher CreateTreeMatcher(EndpointDataSource endpointDataSource)
{
var compositeDataSource = new CompositeEndpointDataSource(new[] { endpointDataSource });
var defaultInlineConstraintResolver = new DefaultInlineConstraintResolver(Options.Create(new RouteOptions()));
return new TreeMatcher(defaultInlineConstraintResolver, NullLogger.Instance, endpointDataSource);
var endpointSelector = new EndpointSelector(
compositeDataSource,
new EndpointConstraintCache(compositeDataSource, new IEndpointConstraintProvider[] { new DefaultEndpointConstraintProvider() }),
NullLoggerFactory.Instance);
return new TreeMatcher(defaultInlineConstraintResolver, NullLogger.Instance, endpointDataSource, endpointSelector);
}
[Fact]
public async Task MatchAsync_DuplicateTemplatesAndDifferentOrder_LowerOrderEndpointMatched()
{
// Arrange
var defaultInlineConstraintResolver = new DefaultInlineConstraintResolver(Options.Create(new RouteOptions()));
var higherOrderEndpoint = CreateEndpoint("/Teams", 1);
var lowerOrderEndpoint = CreateEndpoint("/Teams", 0);