diff --git a/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs b/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs index 6dfc8a79c8d6..5dae735d85d5 100644 --- a/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs +++ b/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs @@ -193,6 +193,15 @@ public static partial class LinkGeneratorRouteValuesAddressExtensions public static string GetUriByRouteValues(this Microsoft.AspNetCore.Routing.LinkGenerator generator, Microsoft.AspNetCore.Http.HttpContext httpContext, string routeName, object values, string scheme = null, Microsoft.AspNetCore.Http.HostString? host = default(Microsoft.AspNetCore.Http.HostString?), Microsoft.AspNetCore.Http.PathString? pathBase = default(Microsoft.AspNetCore.Http.PathString?), Microsoft.AspNetCore.Http.FragmentString fragment = default(Microsoft.AspNetCore.Http.FragmentString), Microsoft.AspNetCore.Routing.LinkOptions options = null) { throw null; } public static string GetUriByRouteValues(this Microsoft.AspNetCore.Routing.LinkGenerator generator, string routeName, object values, string scheme, Microsoft.AspNetCore.Http.HostString host, Microsoft.AspNetCore.Http.PathString pathBase = default(Microsoft.AspNetCore.Http.PathString), Microsoft.AspNetCore.Http.FragmentString fragment = default(Microsoft.AspNetCore.Http.FragmentString), Microsoft.AspNetCore.Routing.LinkOptions options = null) { throw null; } } + public abstract partial class LinkParser + { + protected LinkParser() { } + public abstract Microsoft.AspNetCore.Routing.RouteValueDictionary ParsePathByAddress(TAddress address, Microsoft.AspNetCore.Http.PathString path); + } + public static partial class LinkParserEndpointNameAddressExtensions + { + public static Microsoft.AspNetCore.Routing.RouteValueDictionary ParsePathByEndpointName(this Microsoft.AspNetCore.Routing.LinkParser parser, string endpointName, Microsoft.AspNetCore.Http.PathString path) { throw null; } + } public abstract partial class MatcherPolicy { protected MatcherPolicy() { } diff --git a/src/Http/Routing/src/DefaultLinkParser.cs b/src/Http/Routing/src/DefaultLinkParser.cs new file mode 100644 index 000000000000..8c922e2668bd --- /dev/null +++ b/src/Http/Routing/src/DefaultLinkParser.cs @@ -0,0 +1,235 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Routing +{ + internal class DefaultLinkParser : LinkParser, IDisposable + { + private readonly ParameterPolicyFactory _parameterPolicyFactory; + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + // Caches RoutePatternMatcher instances + private readonly DataSourceDependentCache> _matcherCache; + + // Used to initialize RoutePatternMatcher and constraint instances + private readonly Func _createMatcher; + + public DefaultLinkParser( + ParameterPolicyFactory parameterPolicyFactory, + EndpointDataSource dataSource, + ILogger logger, + IServiceProvider serviceProvider) + { + _parameterPolicyFactory = parameterPolicyFactory; + _logger = logger; + _serviceProvider = serviceProvider; + + // We cache RoutePatternMatcher instances per-Endpoint for performance, but we want to wipe out + // that cache is the endpoints change so that we don't allow unbounded memory growth. + _matcherCache = new DataSourceDependentCache>(dataSource, (_) => + { + // We don't eagerly fill this cache because there's no real reason to. Unlike URL matching, we don't + // need to build a big data structure up front to be correct. + return new ConcurrentDictionary(); + }); + + // Cached to avoid per-call allocation of a delegate on lookup. + _createMatcher = CreateRoutePatternMatcher; + } + + public override RouteValueDictionary ParsePathByAddress(TAddress address, PathString path) + { + var endpoints = GetEndpoints(address); + if (endpoints.Count == 0) + { + return null; + } + + for (var i = 0; i < endpoints.Count; i++) + { + var endpoint = endpoints[i]; + if (TryParse(endpoint, path, out var values)) + { + Log.PathParsingSucceeded(_logger, path, endpoint); + return values; + } + } + + Log.PathParsingFailed(_logger, path, endpoints); + return null; + } + + private List GetEndpoints(TAddress address) + { + var addressingScheme = _serviceProvider.GetRequiredService>(); + var endpoints = addressingScheme.FindEndpoints(address).OfType().ToList(); + + if (endpoints.Count == 0) + { + Log.EndpointsNotFound(_logger, address); + } + else + { + Log.EndpointsFound(_logger, address, endpoints); + } + + return endpoints; + } + + private MatcherState CreateRoutePatternMatcher(RouteEndpoint endpoint) + { + var constraints = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + var policies = endpoint.RoutePattern.ParameterPolicies; + foreach (var kvp in policies) + { + var constraintsForParameter = new List(); + var parameter = endpoint.RoutePattern.GetParameter(kvp.Key); + for (var i = 0; i < kvp.Value.Count; i++) + { + var policy = _parameterPolicyFactory.Create(parameter, kvp.Value[i]); + if (policy is IRouteConstraint constraint) + { + constraintsForParameter.Add(constraint); + } + } + + if (constraintsForParameter.Count > 0) + { + constraints.Add(kvp.Key, constraintsForParameter); + } + } + + var matcher = new RoutePatternMatcher(endpoint.RoutePattern, new RouteValueDictionary(endpoint.RoutePattern.Defaults)); + return new MatcherState(matcher, constraints); + } + + // Internal for testing + internal MatcherState GetMatcherState(RouteEndpoint endpoint) => _matcherCache.EnsureInitialized().GetOrAdd(endpoint, _createMatcher); + + // Internal for testing + internal bool TryParse(RouteEndpoint endpoint, PathString path, out RouteValueDictionary values) + { + var (matcher, constraints) = GetMatcherState(endpoint); + + values = new RouteValueDictionary(); + if (!matcher.TryMatch(path, values)) + { + values = null; + return false; + } + + foreach (var kvp in constraints) + { + for (var i = 0; i < kvp.Value.Count; i++) + { + var constraint = kvp.Value[i]; + if (!constraint.Match(httpContext: null, NullRouter.Instance, kvp.Key, values, RouteDirection.IncomingRequest)) + { + values = null; + return false; + } + } + } + + return true; + } + + public void Dispose() + { + _matcherCache.Dispose(); + } + + // internal for testing + internal readonly struct MatcherState + { + public readonly RoutePatternMatcher Matcher; + public readonly Dictionary> Constraints; + + public MatcherState(RoutePatternMatcher matcher, Dictionary> constraints) + { + Matcher = matcher; + Constraints = constraints; + } + + public void Deconstruct(out RoutePatternMatcher matcher, out Dictionary> constraints) + { + matcher = Matcher; + constraints = Constraints; + } + } + + private static class Log + { + public static class EventIds + { + public static readonly EventId EndpointsFound = new EventId(100, "EndpointsFound"); + public static readonly EventId EndpointsNotFound = new EventId(101, "EndpointsNotFound"); + + public static readonly EventId PathParsingSucceeded = new EventId(102, "PathParsingSucceeded"); + public static readonly EventId PathParsingFailed = new EventId(103, "PathParsingFailed"); + } + + private static readonly Action, object, Exception> _endpointsFound = LoggerMessage.Define, object>( + LogLevel.Debug, + EventIds.EndpointsFound, + "Found the endpoints {Endpoints} for address {Address}"); + + private static readonly Action _endpointsNotFound = LoggerMessage.Define( + LogLevel.Debug, + EventIds.EndpointsNotFound, + "No endpoints found for address {Address}"); + + private static readonly Action _pathParsingSucceeded = LoggerMessage.Define( + LogLevel.Debug, + EventIds.PathParsingSucceeded, + "Path parsing succeeded for endpoint {Endpoint} and URI path {URI}"); + + private static readonly Action, string, Exception> _pathParsingFailed = LoggerMessage.Define, string>( + LogLevel.Debug, + EventIds.PathParsingFailed, + "Path parsing failed for endpoints {Endpoints} and URI path {URI}"); + + public static void EndpointsFound(ILogger logger, object address, IEnumerable endpoints) + { + // Checking level again to avoid allocation on the common path + if (logger.IsEnabled(LogLevel.Debug)) + { + _endpointsFound(logger, endpoints.Select(e => e.DisplayName), address, null); + } + } + + public static void EndpointsNotFound(ILogger logger, object address) + { + _endpointsNotFound(logger, address, null); + } + + public static void PathParsingSucceeded(ILogger logger, PathString path, Endpoint endpoint) + { + // Checking level again to avoid allocation on the common path + if (logger.IsEnabled(LogLevel.Debug)) + { + _pathParsingSucceeded(logger, endpoint.DisplayName, path.Value, null); + } + } + + public static void PathParsingFailed(ILogger logger, PathString path, IEnumerable endpoints) + { + // Checking level again to avoid allocation on the common path + if (logger.IsEnabled(LogLevel.Debug)) + { + _pathParsingFailed(logger, endpoints.Select(e => e.DisplayName), path.Value, null); + } + } + } + } +} diff --git a/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs b/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs index 23e080867b80..cc61b3e490a9 100644 --- a/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs +++ b/src/Http/Routing/src/DependencyInjection/RoutingServiceCollectionExtensions.cs @@ -85,6 +85,7 @@ public static IServiceCollection AddRouting(this IServiceCollection services) services.TryAddSingleton(); services.TryAddSingleton, EndpointNameAddressScheme>(); services.TryAddSingleton, RouteValuesAddressScheme>(); + services.TryAddSingleton(); // // Endpoint Selection diff --git a/src/Http/Routing/src/LinkParser.cs b/src/Http/Routing/src/LinkParser.cs new file mode 100644 index 000000000000..b5135b3f014e --- /dev/null +++ b/src/Http/Routing/src/LinkParser.cs @@ -0,0 +1,37 @@ +// 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; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Defines a contract to parse URIs using information from routing. + /// + public abstract class LinkParser + { + /// + /// Attempts to parse the provided using the route pattern + /// specified by the matching . + /// + /// The address type. + /// The address value. Used to resolve endpoints. + /// The URI path to parse. + /// + /// A with the parsed values if parsing is successful; + /// otherwise null. + /// + /// + /// + /// will attempt to first resolve + /// instances that match and then use the route + /// pattern associated with each endpoint to parse the URL path. + /// + /// + /// The parsing operation will fail and return null if either no endpoints are found or none + /// of the route patterns match the provided URI path. + /// + /// + public abstract RouteValueDictionary ParsePathByAddress(TAddress address, PathString path); + } +} diff --git a/src/Http/Routing/src/LinkParserEndpointNameAddressExtensions.cs b/src/Http/Routing/src/LinkParserEndpointNameAddressExtensions.cs new file mode 100644 index 000000000000..904dc0b885bc --- /dev/null +++ b/src/Http/Routing/src/LinkParserEndpointNameAddressExtensions.cs @@ -0,0 +1,54 @@ +// 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 Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Extension methods for using with an endpoint name. + /// + public static class LinkParserEndpointNameAddressExtensions + { + /// + /// Attempts to parse the provided using the route pattern + /// specified by the matching . + /// + /// The . + /// The endpoint name. Used to resolve endpoints. + /// The URI path to parse. + /// + /// A with the parsed values if parsing is successful; + /// otherwise null. + /// + /// + /// + /// will attempt to first resolve + /// instances that match and then use the route + /// pattern associated with each endpoint to parse the URL path. + /// + /// + /// The parsing operation will fail and return null if either no endpoints are found or none + /// of the route patterns match the provided URI path. + /// + /// + public static RouteValueDictionary ParsePathByEndpointName( + this LinkParser parser, + string endpointName, + PathString path) + { + if (parser == null) + { + throw new ArgumentNullException(nameof(parser)); + } + + if (endpointName == null) + { + throw new ArgumentNullException(nameof(endpointName)); + } + + return parser.ParsePathByAddress(endpointName, path); + } + } +} diff --git a/src/Http/Routing/test/UnitTests/DefaultLinkParserTest.cs b/src/Http/Routing/test/UnitTests/DefaultLinkParserTest.cs new file mode 100644 index 000000000000..d98ceb6a7723 --- /dev/null +++ b/src/Http/Routing/test/UnitTests/DefaultLinkParserTest.cs @@ -0,0 +1,192 @@ +// 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 Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.AspNetCore.Routing.TestObjects; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Routing +{ + // Tests LinkParser functionality using ParsePathByAddress - see tests for the extension + // methods for more E2E tests. + // + // Does not cover template processing in detail, those scenarios are validated by other tests. + public class DefaultLinkParserTest : LinkParserTestBase + { + [Fact] + public void ParsePathByAddresss_NoMatchingEndpoint_ReturnsNull() + { + // Arrange + var endpoint = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id?}", displayName: "Test1", metadata: new object[] { new IntMetadata(1), }); + + var sink = new TestSink(); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var parser = CreateLinkParser(services => { services.AddSingleton(loggerFactory); }, endpoint); + + // Act + var values = parser.ParsePathByAddress(0, "/Home/Index/17"); + + // Assert + Assert.Null(values); + + Assert.Collection( + sink.Writes, + w => Assert.Equal("No endpoints found for address 0", w.Message)); + } + + [Fact] + public void ParsePathByAddresss_HasMatches_ReturnsNullWhenParsingFails() + { + // Arrange + var endpoint1 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id}", displayName: "Test1", metadata: new object[] { new IntMetadata(1), }); + var endpoint2 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id2}", displayName: "Test2", metadata: new object[] { new IntMetadata(0), }); + + var sink = new TestSink(); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var parser = CreateLinkParser(services => { services.AddSingleton(loggerFactory); }, endpoint1, endpoint2); + + // Act + var values = parser.ParsePathByAddress(0, "/"); + + // Assert + Assert.Null(values); + + Assert.Collection( + sink.Writes, + w => Assert.Equal("Found the endpoints Test2 for address 0", w.Message), + w => Assert.Equal("Path parsing failed for endpoints Test2 and URI path /", w.Message)); + } + + [Fact] + public void ParsePathByAddresss_HasMatches_ReturnsFirstSuccessfulParse() + { + // Arrange + var endpoint0 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}", displayName: "Test1",metadata: new object[] { new IntMetadata(0), }); + var endpoint1 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id}", displayName: "Test2", metadata: new object[] { new IntMetadata(0), }); + var endpoint2 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id2}", displayName: "Test3", metadata: new object[] { new IntMetadata(0), }); + + var sink = new TestSink(); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var parser = CreateLinkParser(services => { services.AddSingleton(loggerFactory); }, endpoint0, endpoint1, endpoint2); + + // Act + var values = parser.ParsePathByAddress(0, "/Home/Index/17"); + + // Assert + MatcherAssert.AssertRouteValuesEqual(new { controller= "Home", action = "Index", id = "17" }, values); + + Assert.Collection( + sink.Writes, + w => Assert.Equal("Found the endpoints Test1, Test2, Test3 for address 0", w.Message), + w => Assert.Equal("Path parsing succeeded for endpoint Test2 and URI path /Home/Index/17", w.Message)); + } + + [Fact] + public void ParsePathByAddresss_HasMatches_IncludesDefaults() + { + // Arrange + var endpoint = EndpointFactory.CreateRouteEndpoint("{controller=Home}/{action=Index}/{id?}", metadata: new object[] { new IntMetadata(0), }); + + var parser = CreateLinkParser(endpoint); + + // Act + var values = parser.ParsePathByAddress(0, "/"); + + // Assert + MatcherAssert.AssertRouteValuesEqual(new { controller = "Home", action = "Index", }, values); + } + + [Fact] + public void ParsePathByAddresss_HasMatches_RunsConstraints() + { + // Arrange + var endpoint0 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id:int}", metadata: new object[] { new IntMetadata(0), }); + var endpoint1 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id2:alpha}", metadata: new object[] { new IntMetadata(0), }); + + var parser = CreateLinkParser(endpoint0, endpoint1); + + // Act + var values = parser.ParsePathByAddress(0, "/Home/Index/abc"); + + // Assert + MatcherAssert.AssertRouteValuesEqual(new { controller = "Home", action = "Index", id2 = "abc" }, values); + } + + [Fact] + public void GetRoutePatternMatcher_CanCache() + { + // Arrange + var endpoint1 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id}", metadata: new object[] { new IntMetadata(1), }); + var dataSource = new DynamicEndpointDataSource(endpoint1); + + var parser = CreateLinkParser(dataSources: new[] { dataSource }); + + var expected = parser.GetMatcherState(endpoint1); + + // Act + var actual = parser.GetMatcherState(endpoint1); + + // Assert + Assert.Same(expected.Matcher, actual.Matcher); + Assert.Same(expected.Constraints, actual.Constraints); + } + + [Fact] + public void GetRoutePatternMatcherr_CanClearCache() + { + // Arrange + var endpoint1 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id}", metadata: new object[] { new IntMetadata(1), }); + var dataSource = new DynamicEndpointDataSource(endpoint1); + + var parser = CreateLinkParser(dataSources: new[] { dataSource }); + var original = parser.GetMatcherState(endpoint1); + + var endpoint2 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id}", metadata: new object[] { new IntMetadata(1), }); + dataSource.AddEndpoint(endpoint2); + + // Act + var actual = parser.GetMatcherState(endpoint1); + + // Assert + Assert.NotSame(original.Matcher, actual.Matcher); + Assert.NotSame(original.Constraints, actual.Constraints); + } + + protected override void AddAdditionalServices(IServiceCollection services) + { + services.AddSingleton, IntAddressScheme>(); + } + + private class IntAddressScheme : IEndpointAddressScheme + { + private readonly EndpointDataSource _dataSource; + + public IntAddressScheme(EndpointDataSource dataSource) + { + _dataSource = dataSource; + } + + public IEnumerable FindEndpoints(int address) + { + return _dataSource.Endpoints.Where(e => e.Metadata.GetMetadata().Value == address); + } + } + + private class IntMetadata + { + public IntMetadata(int value) + { + Value = value; + } + public int Value { get; } + } + } +} diff --git a/src/Http/Routing/test/UnitTests/LinkParserEndpointNameExtensionsTest.cs b/src/Http/Routing/test/UnitTests/LinkParserEndpointNameExtensionsTest.cs new file mode 100644 index 000000000000..bdc650d77ba8 --- /dev/null +++ b/src/Http/Routing/test/UnitTests/LinkParserEndpointNameExtensionsTest.cs @@ -0,0 +1,57 @@ +// 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.Routing.Matching; +using Xunit; + +namespace Microsoft.AspNetCore.Routing +{ + public class LinkParserEndpointNameExtensionsTest : LinkParserTestBase + { + [Fact] + public void ParsePathByAddresss_NoMatchingEndpoint_ReturnsNull() + { + // Arrange + var endpoint = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id?}", metadata: new object[] { new EndpointNameMetadata("Test2"), }); + + var parser = CreateLinkParser(endpoint); + + // Act + var values = parser.ParsePathByEndpointName("Test", "/Home/Index/17"); + + // Assert + Assert.Null(values); + } + + [Fact] + public void ParsePathByAddresss_HasMatches_ReturnsNullWhenParsingFails() + { + // Arrange + var endpoint1 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id}", metadata: new object[] { new EndpointNameMetadata("Test2"), }); + var endpoint2 = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id2}", metadata: new object[] { new EndpointNameMetadata("Test"), }); + + var parser = CreateLinkParser(endpoint1, endpoint2); + + // Act + var values = parser.ParsePathByEndpointName("Test", "/"); + + // Assert + Assert.Null(values); + } + + [Fact] // Endpoint name does not support multiple matches + public void ParsePathByAddresss_HasMatches_ReturnsFirstSuccessfulParse() + { + // Arrange + var endpoint = EndpointFactory.CreateRouteEndpoint("{controller}/{action}/{id}", metadata: new object[] { new EndpointNameMetadata("Test"), }); + + var parser = CreateLinkParser(endpoint); + + // Act + var values = parser.ParsePathByEndpointName("Test", "/Home/Index/17"); + + // Assert + MatcherAssert.AssertRouteValuesEqual(new { controller = "Home", action = "Index", id = "17" }, values); + } + } +} diff --git a/src/Http/Routing/test/UnitTests/LinkParserTestBase.cs b/src/Http/Routing/test/UnitTests/LinkParserTestBase.cs new file mode 100644 index 000000000000..6cf48435b487 --- /dev/null +++ b/src/Http/Routing/test/UnitTests/LinkParserTestBase.cs @@ -0,0 +1,74 @@ +// 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 Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Routing +{ + public abstract class LinkParserTestBase + { + protected ServiceCollection GetBasicServices() + { + var services = new ServiceCollection(); + services.AddOptions(); + services.AddRouting(); + services.AddLogging(); + return services; + } + + protected virtual void AddAdditionalServices(IServiceCollection services) + { + } + + private protected DefaultLinkParser CreateLinkParser(params Endpoint[] endpoints) + { + return CreateLinkParser(configureServices: null, endpoints); + } + + private protected DefaultLinkParser CreateLinkParser( + Action configureServices, + params Endpoint[] endpoints) + { + return CreateLinkParser(configureServices, new[] { new DefaultEndpointDataSource(endpoints ?? Array.Empty()) }); + } + + private protected DefaultLinkParser CreateLinkParser(EndpointDataSource[] dataSources) + { + return CreateLinkParser(configureServices: null, dataSources); + } + + private protected DefaultLinkParser CreateLinkParser( + Action configureServices, + EndpointDataSource[] dataSources) + { + var services = GetBasicServices(); + AddAdditionalServices(services); + configureServices?.Invoke(services); + + services.Configure(o => + { + if (dataSources != null) + { + foreach (var dataSource in dataSources) + { + o.EndpointDataSources.Add(dataSource); + } + } + }); + + var serviceProvider = services.BuildServiceProvider(); + var routeOptions = serviceProvider.GetRequiredService>(); + + return new DefaultLinkParser( + new DefaultParameterPolicyFactory(routeOptions, serviceProvider), + new CompositeEndpointDataSource(routeOptions.Value.EndpointDataSources), + serviceProvider.GetRequiredService().CreateLogger(), + serviceProvider); + } + } +} diff --git a/src/Http/Routing/test/UnitTests/Matching/MatcherAssert.cs b/src/Http/Routing/test/UnitTests/Matching/MatcherAssert.cs index 8f634f1a346f..1cf3b3135099 100644 --- a/src/Http/Routing/test/UnitTests/Matching/MatcherAssert.cs +++ b/src/Http/Routing/test/UnitTests/Matching/MatcherAssert.cs @@ -12,6 +12,21 @@ namespace Microsoft.AspNetCore.Routing.Matching { internal static class MatcherAssert { + public static void AssertRouteValuesEqual(object expectedValues, RouteValueDictionary actualValues) + { + AssertRouteValuesEqual(new RouteValueDictionary(expectedValues), actualValues); + } + + public static void AssertRouteValuesEqual(RouteValueDictionary expectedValues, RouteValueDictionary actualValues) + { + if (expectedValues.Count != actualValues.Count || + !expectedValues.OrderBy(kvp => kvp.Key).SequenceEqual(actualValues.OrderBy(kvp => kvp.Key))) + { + throw new XunitException( + $"Expected values:{FormatRouteValues(expectedValues)} Actual values: {FormatRouteValues(actualValues)}."); + } + } + public static void AssertMatch(EndpointSelectorContext context, HttpContext httpContext, Endpoint expected) { AssertMatch(context, httpContext, expected, new RouteValueDictionary()); diff --git a/src/Mvc/test/Mvc.FunctionalTests/LinkParserTest.cs b/src/Mvc/test/Mvc.FunctionalTests/LinkParserTest.cs new file mode 100644 index 000000000000..15c60602cb47 --- /dev/null +++ b/src/Mvc/test/Mvc.FunctionalTests/LinkParserTest.cs @@ -0,0 +1,96 @@ +// 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.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + // Functional tests for MVC's scenarios with LinkParser + public class LinkParserTest : IClassFixture> + { + public LinkParserTest(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = factory.CreateDefaultClient(); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => + builder.UseStartup(); + + public HttpClient Client { get; } + + [Fact] + public async Task ParsePathByEndpoint_CanParsedWithDefaultRoute() + { + // Act + var response = await Client.GetAsync("LinkParser/Index/18"); + var values = await response.Content.ReadAsAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Collection( + values.Properties().OrderBy(p => p.Name), + p => + { + Assert.Equal("action", p.Name); + Assert.Equal("Index", p.Value.Value()); + }, + p => + { + Assert.Equal("controller", p.Name); + Assert.Equal("LinkParser", p.Value.Value()); + }, + p => + { + Assert.Equal("id", p.Name); + Assert.Equal("18", p.Value.Value()); + }); + } + + [Fact] + public async Task ParsePathByEndpoint_CanParsedWithNamedAttributeRoute() + { + // Act + // + // %2F => / + var response = await Client.GetAsync("LinkParser/Another?path=%2Fsome-path%2Fa%2Fb%2Fc"); + var values = await response.Content.ReadAsAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Collection( + values.Properties().OrderBy(p => p.Name), + p => + { + Assert.Equal("action", p.Name); + Assert.Equal("AnotherRoute", p.Value.Value()); + }, + p => + { + Assert.Equal("controller", p.Name); + Assert.Equal("LinkParser", p.Value.Value()); + }, + p => + { + Assert.Equal("x", p.Name); + Assert.Equal("a", p.Value.Value()); + }, + p => + { + Assert.Equal("y", p.Name); + Assert.Equal("b", p.Value.Value()); + }, + p => + { + Assert.Equal("z", p.Name); + Assert.Equal("c", p.Value.Value()); + }); + } + } +} diff --git a/src/Mvc/test/WebSites/RoutingWebSite/Controllers/LinkParserController.cs b/src/Mvc/test/WebSites/RoutingWebSite/Controllers/LinkParserController.cs new file mode 100644 index 000000000000..614a207d1587 --- /dev/null +++ b/src/Mvc/test/WebSites/RoutingWebSite/Controllers/LinkParserController.cs @@ -0,0 +1,60 @@ +// 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.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Newtonsoft.Json.Linq; + +namespace RoutingWebSite.Controllers +{ + public class LinkParserController : Controller + { + private readonly LinkParser _linkParser; + + public LinkParserController(LinkParser linkParser) + { + _linkParser = linkParser; + } + + public JObject Index() + { + var parsed = _linkParser.ParsePathByEndpointName("default", HttpContext.Request.Path); + if (parsed == null) + { + throw new Exception("Parsing failed."); + } + + return ToJObject(parsed); + } + + public JObject Another(string path) + { + var parsed = _linkParser.ParsePathByEndpointName("AnotherRoute", path); + if (parsed == null) + { + throw new Exception("Parsing failed."); + } + + return ToJObject(parsed); + } + + [Route("some-path/{x}/{y}/{z?}", Name = "AnotherRoute")] + public void AnotherRoute() + { + throw null; + } + + private static JObject ToJObject(RouteValueDictionary values) + { + var obj = new JObject(); + foreach (var kvp in values) + { + obj.Add(kvp.Key, new JValue(kvp.Value.ToString())); + } + + return obj; + } + } +}