diff --git a/samples/RoutingSample.Web/Program.cs b/samples/RoutingSample.Web/Program.cs index 5867596559..07fe40f30f 100644 --- a/samples/RoutingSample.Web/Program.cs +++ b/samples/RoutingSample.Web/Program.cs @@ -4,6 +4,7 @@ using System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; namespace RoutingSample.Web { diff --git a/samples/RoutingSample.Web/RoutingSample.Web.csproj b/samples/RoutingSample.Web/RoutingSample.Web.csproj index 731de09658..4c62c17f60 100644 --- a/samples/RoutingSample.Web/RoutingSample.Web.csproj +++ b/samples/RoutingSample.Web/RoutingSample.Web.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Microsoft.AspNetCore.Routing/DefaultLinkGenerator.cs b/src/Microsoft.AspNetCore.Routing/DefaultLinkGenerator.cs index df52926a02..f050eecb97 100644 --- a/src/Microsoft.AspNetCore.Routing/DefaultLinkGenerator.cs +++ b/src/Microsoft.AspNetCore.Routing/DefaultLinkGenerator.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; @@ -19,7 +20,6 @@ namespace Microsoft.AspNetCore.Routing { internal sealed class DefaultLinkGenerator : LinkGenerator { - private static readonly char[] UrlQueryDelimiters = new char[] { '?', '#' }; private readonly ParameterPolicyFactory _parameterPolicyFactory; private readonly ObjectPool _uriBuildingContextPool; private readonly ILogger _logger; @@ -172,7 +172,18 @@ namespace Microsoft.AspNetCore.Routing private List GetEndpoints(TAddress address) { var addressingScheme = _serviceProvider.GetRequiredService>(); - return addressingScheme.FindEndpoints(address).OfType().ToList(); + var endpoints = addressingScheme.FindEndpoints(address).OfType().ToList(); + + if (endpoints.Count == 0) + { + Log.EndpointsNotFound(_logger, address); + } + else + { + Log.EndpointsFound(_logger, address, endpoints); + } + + return endpoints; } // Also called from DefaultLinkGenerationTemplate @@ -195,15 +206,17 @@ namespace Microsoft.AspNetCore.Routing options, out var result)) { - - return UriHelper.BuildRelative( + var uri = UriHelper.BuildRelative( pathBase, result.path, result.query, fragment); + Log.LinkGenerationSucceeded(_logger, endpoints, uri); + return uri; } } + Log.LinkGenerationFailed(_logger, endpoints); return null; } @@ -229,16 +242,19 @@ namespace Microsoft.AspNetCore.Routing options, out var result)) { - return UriHelper.BuildAbsolute( + var uri = UriHelper.BuildAbsolute( scheme, host, pathBase, result.path, result.query, fragment); + Log.LinkGenerationSucceeded(_logger, endpoints, uri); + return uri; } } + Log.LinkGenerationFailed(_logger, endpoints); return null; } @@ -266,20 +282,25 @@ namespace Microsoft.AspNetCore.Routing { // We're missing one of the required values for this route. result = default; + Log.TemplateFailedRequiredValues(_logger, endpoint, ambientValues, explicitValues); return false; } if (!MatchesConstraints(httpContext, endpoint, templateValuesResult.CombinedValues)) { result = default; + + // MatchesConstraints does its own logging, so we're not logging here. return false; } if (!templateBinder.TryBindValues(templateValuesResult.AcceptedValues, options, _globalLinkOptions, out result)) { + Log.TemplateFailedExpansion(_logger, endpoint, templateValuesResult.AcceptedValues); return false; } + Log.TemplateSucceeded(_logger, endpoint, result.path, result.query); return true; } @@ -304,6 +325,7 @@ namespace Microsoft.AspNetCore.Routing if (parameterPolicy is IRouteConstraint routeConstraint && !routeConstraint.Match(httpContext, NullRouter.Instance, kvp.Key, routeValues, RouteDirection.UrlGeneration)) { + Log.TemplateFailedConstraint(_logger, endpoint, kvp.Key, routeConstraint, routeValues); return false; } } @@ -317,5 +339,163 @@ namespace Microsoft.AspNetCore.Routing { return httpContext?.Features.Get()?.RouteValues; } + + 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 TemplateSucceeded = new EventId(102, "TemplateSucceeded"); + public static readonly EventId TemplateFailedRequiredValues = new EventId(103, "TemplateFailedRequiredValues"); + public static readonly EventId TemplateFailedConstraint = new EventId(103, "TemplateFailedConstraint"); + public static readonly EventId TemplateFailedExpansion = new EventId(104, "TemplateFailedExpansion"); + + public static readonly EventId LinkGenerationSucceeded = new EventId(105, "LinkGenerationSucceeded"); + public static readonly EventId LinkGenerationFailed = new EventId(106, "LinkGenerationFailed"); + } + + 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 _templateSucceeded = LoggerMessage.Define( + LogLevel.Debug, + EventIds.TemplateSucceeded, + "Successfully processed template {Template} for {Endpoint} resulting in {Path} and {Query}"); + + private static readonly Action _templateFailedRequiredValues = LoggerMessage.Define( + LogLevel.Debug, + EventIds.TemplateFailedRequiredValues, + "Failed to process the template {Template} for {Endpoint}. " + + "A required route value is missing, or has a different value from the required default values." + + "Supplied ambient values {AmbientValues} and {Values} with default values {Defaults}"); + + private static readonly Action _templateFailedConstraint = LoggerMessage.Define( + LogLevel.Debug, + EventIds.TemplateFailedConstraint, + "Failed to process the template {Template} for {Endpoint}. " + + "The constraint {Constraint} for parameter {ParameterName} failed with values {Values}"); + + private static readonly Action _templateFailedExpansion = LoggerMessage.Define( + LogLevel.Debug, + EventIds.TemplateFailedExpansion, + "Failed to process the template {Template} for {Endpoint}. " + + "The failure occured while expanding the template with values {Values}. " + + "This is usually due to a missing or empty value in a complex segment."); + + private static readonly Action, string, Exception> _linkGenerationSucceeded = LoggerMessage.Define, string>( + LogLevel.Debug, + EventIds.LinkGenerationSucceeded, + "Link generation succeeded for endpoints {Endpoints} with result {URI}"); + + private static readonly Action, Exception> _linkGenerationFailed = LoggerMessage.Define>( + LogLevel.Debug, + EventIds.LinkGenerationFailed, + "Link generation failed for endpoints {Endpoints}"); + + 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 TemplateSucceeded(ILogger logger, RouteEndpoint endpoint, PathString path, QueryString query) + { + _templateSucceeded(logger, endpoint.RoutePattern.RawText, endpoint.DisplayName, path.Value, query.Value, null); + } + + public static void TemplateFailedRequiredValues(ILogger logger, RouteEndpoint endpoint, RouteValueDictionary ambientValues, RouteValueDictionary values) + { + // Checking level again to avoid allocation on the common path + if (logger.IsEnabled(LogLevel.Debug)) + { + _templateFailedRequiredValues(logger, endpoint.RoutePattern.RawText, endpoint.DisplayName, FormatRouteValues(ambientValues), FormatRouteValues(values), FormatRouteValues(endpoint.RoutePattern.Defaults), null); + } + } + + public static void TemplateFailedConstraint(ILogger logger, RouteEndpoint endpoint, string parameterName, IRouteConstraint constraint, RouteValueDictionary values) + { + // Checking level again to avoid allocation on the common path + if (logger.IsEnabled(LogLevel.Debug)) + { + _templateFailedConstraint(logger, endpoint.RoutePattern.RawText, endpoint.DisplayName, constraint, parameterName, FormatRouteValues(values), null); + } + } + + public static void TemplateFailedExpansion(ILogger logger, RouteEndpoint endpoint, RouteValueDictionary values) + { + // Checking level again to avoid allocation on the common path + if (logger.IsEnabled(LogLevel.Debug)) + { + _templateFailedExpansion(logger, endpoint.RoutePattern.RawText, endpoint.DisplayName, FormatRouteValues(values), null); + } + } + + public static void LinkGenerationSucceeded(ILogger logger, IEnumerable endpoints, string uri) + { + // Checking level again to avoid allocation on the common path + if (logger.IsEnabled(LogLevel.Debug)) + { + _linkGenerationSucceeded(logger, endpoints.Select(e => e.DisplayName), uri, null); + } + } + + public static void LinkGenerationFailed(ILogger logger, IEnumerable endpoints) + { + // Checking level again to avoid allocation on the common path + if (logger.IsEnabled(LogLevel.Debug)) + { + _linkGenerationFailed(logger, endpoints.Select(e => e.DisplayName), null); + } + } + + // EXPENSIVE: should only be used at Debug and higher levels of logging. + private static string FormatRouteValues(IReadOnlyDictionary values) + { + if (values == null || values.Count == 0) + { + return "{ }"; + } + + var builder = new StringBuilder(); + builder.Append("{ "); + + foreach (var kvp in values.OrderBy(kvp => kvp.Key)) + { + builder.Append("\""); + builder.Append(kvp.Key); + builder.Append("\""); + builder.Append(":"); + builder.Append(" "); + builder.Append("\""); + builder.Append(kvp.Value); + builder.Append("\""); + builder.Append(", "); + } + + // Trim trailing ", " + builder.Remove(builder.Length - 2, 2); + + builder.Append(" }"); + + return builder.ToString(); + } + } } } diff --git a/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs index 3443ebc6db..4db8249104 100644 --- a/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Routing/DependencyInjection/RoutingServiceCollectionExtensions.cs @@ -74,8 +74,9 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddSingleton(); // Link generation related services - services.TryAddSingleton, RouteValuesBasedEndpointFinder>(); services.TryAddSingleton(); + services.TryAddSingleton, EndpointNameEndpointFinder>(); + services.TryAddSingleton, RouteValuesBasedEndpointFinder>(); // // Endpoint Selection diff --git a/src/Microsoft.AspNetCore.Routing/EndpointNameEndpointFinder.cs b/src/Microsoft.AspNetCore.Routing/EndpointNameEndpointFinder.cs new file mode 100644 index 0000000000..404ffde04b --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/EndpointNameEndpointFinder.cs @@ -0,0 +1,107 @@ +// 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.Text; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing +{ + internal class EndpointNameEndpointFinder : IEndpointFinder + { + private readonly DataSourceDependentCache> _cache; + + public EndpointNameEndpointFinder(CompositeEndpointDataSource dataSource) + { + _cache = new DataSourceDependentCache>(dataSource, Initialize); + } + + // Internal for tests + internal Dictionary Entries => _cache.EnsureInitialized(); + + public IEnumerable FindEndpoints(string address) + { + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + // Capture the current value of the cache + var entries = Entries; + + entries.TryGetValue(address, out var result); + return result ?? Array.Empty(); + } + + private static Dictionary Initialize(IReadOnlyList endpoints) + { + // Collect duplicates as we go, blow up on startup if we find any. + var hasDuplicates = false; + + var entries = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < endpoints.Count; i++) + { + var endpoint = endpoints[i]; + + var endpointName = GetEndpointName(endpoint); + if (endpointName == null) + { + continue; + } + + if (!entries.TryGetValue(endpointName, out var existing)) + { + // This isn't a duplicate (so far) + entries[endpointName] = new[] { endpoint }; + continue; + } + + // Ok this is a duplicate, because we have two endpoints with the same name. Bail out, because we + // are just going to throw, we don't need to finish collecting data. + hasDuplicates = true; + break; + } + + if (!hasDuplicates) + { + // No duplicates, success! + return entries; + } + + // OK we need to report some duplicates. + var duplicates = endpoints + .GroupBy(e => GetEndpointName(e)) + .Where(g => g.Key != null) + .Where(g => g.Count() > 1); + + var builder = new StringBuilder(); + builder.AppendLine(Resources.DuplicateEndpointNameHeader); + + foreach (var group in duplicates) + { + builder.AppendLine(); + builder.AppendLine(Resources.FormatDuplicateEndpointNameEntry(group.Key)); + + foreach (var endpoint in group) + { + builder.AppendLine(endpoint.DisplayName); + } + } + + throw new InvalidOperationException(builder.ToString()); + + string GetEndpointName(Endpoint endpoint) + { + if (endpoint.Metadata.GetMetadata() != null) + { + // Skip anything that's suppressed for linking. + return null; + } + + return endpoint.Metadata.GetMetadata()?.EndpointName; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/EndpointNameMetadata.cs b/src/Microsoft.AspNetCore.Routing/EndpointNameMetadata.cs new file mode 100644 index 0000000000..925c355807 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/EndpointNameMetadata.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 System; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Specifies an endpoint name in . + /// + /// + /// Endpoint names must be unique within an application, and can be used to unambiguously + /// identify a desired endpoint for URI generation using . + /// + public class EndpointNameMetadata : IEndpointNameMetadata + { + /// + /// Creates a new with the provided endpoint name. + /// + /// The endpoint name. + public EndpointNameMetadata(string endpointName) + { + if (endpointName == null) + { + throw new ArgumentNullException(nameof(endpointName)); + } + + EndpointName = endpointName; + } + + /// + /// Gets the endpoint name. + /// + public string EndpointName { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/IEndpointNameMetadata.cs b/src/Microsoft.AspNetCore.Routing/IEndpointNameMetadata.cs new file mode 100644 index 0000000000..e2e3b85fda --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/IEndpointNameMetadata.cs @@ -0,0 +1,22 @@ +// 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 use to specify an endpoint name in . + /// + /// + /// Endpoint names must be unique within an application, and can be used to unambiguously + /// identify a desired endpoint for URI generation using . + /// + public interface IEndpointNameMetadata + { + /// + /// Gets the endpoint name. + /// + string EndpointName { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/LinkGeneratorEndpointNameAddressExtensions.cs b/src/Microsoft.AspNetCore.Routing/LinkGeneratorEndpointNameAddressExtensions.cs new file mode 100644 index 0000000000..ab65be757e --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/LinkGeneratorEndpointNameAddressExtensions.cs @@ -0,0 +1,177 @@ +// 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; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Extension methods for using with and endpoint name. + /// + public static class LinkGeneratorEndpointNameAddressExtensions + { + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// The associated with the current request. + /// The endpoint name. Used to resolve endpoints. + /// The route values. Used to expand parameters in the route template. Optional. + /// An optional URI fragment. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null. + public static string GetPathByName( + this LinkGenerator generator, + HttpContext httpContext, + string endpointName, + object values, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (endpointName == null) + { + throw new ArgumentNullException(nameof(endpointName)); + } + + return generator.GetPathByAddress(httpContext, endpointName, new RouteValueDictionary(values), fragment, options); + } + + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// The endpoint name. Used to resolve endpoints. + /// The route values. Used to expand parameters in the route template. Optional. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// An optional URI fragment. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null. + public static string GetPathByName( + this LinkGenerator generator, + string endpointName, + object values, + PathString pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (endpointName == null) + { + throw new ArgumentNullException(nameof(endpointName)); + } + + return generator.GetPathByAddress(endpointName, new RouteValueDictionary(values), pathBase, fragment, options); + } + + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The associated with the current request. + /// The endpoint name. Used to resolve endpoints. + /// The route values. Used to expand parameters in the route template. Optional. + /// An optional URI fragment. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null. + public static string GetUriByName( + this LinkGenerator generator, + HttpContext httpContext, + string endpointName, + object values, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (endpointName == null) + { + throw new ArgumentNullException(nameof(endpointName)); + } + + return generator.GetUriByAddress(httpContext, endpointName, new RouteValueDictionary(values), fragment, options); + } + + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The endpoint name. Used to resolve endpoints. + /// The route values. Used to expand parameters in the route template. Optional. + /// The URI scheme, applied to the resulting URI. + /// The URI host/authority, applied to the resulting URI. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// An optional URI fragment. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// An absolute URI, or null. + public static string GetUriByName( + this LinkGenerator generator, + string endpointName, + object values, + string scheme, + HostString host, + PathString pathBase = default, + FragmentString fragment = default, + LinkOptions options = default) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (endpointName == null) + { + throw new ArgumentNullException(nameof(endpointName)); + } + + return generator.GetUriByAddress(endpointName, new RouteValueDictionary(values), scheme, host, pathBase, fragment, options); + } + + /// + /// Gets a based on the provided . + /// + /// The . + /// The endpoint name. Used to resolve endpoints. Optional. + /// + /// A if one or more endpoints matching the address can be found, otherwise null. + /// + public static LinkGenerationTemplate GetTemplateByName(this LinkGenerator generator, string endpointName) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + + if (endpointName == null) + { + throw new ArgumentNullException(nameof(endpointName)); + } + + return generator.GetTemplateByAddress(endpointName); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/LinkGeneratorRouteValuesAddressExtensions.cs b/src/Microsoft.AspNetCore.Routing/LinkGeneratorRouteValuesAddressExtensions.cs index 0e2f8a663c..3f8014f8e2 100644 --- a/src/Microsoft.AspNetCore.Routing/LinkGeneratorRouteValuesAddressExtensions.cs +++ b/src/Microsoft.AspNetCore.Routing/LinkGeneratorRouteValuesAddressExtensions.cs @@ -11,6 +11,19 @@ namespace Microsoft.AspNetCore.Routing /// public static class LinkGeneratorRouteValuesAddressExtensions { + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// The associated with the current request. + /// The route name. Used to resolve endpoints. Optional. + /// The route values. Used to resolve endpoints and expand parameters in the route template. Optional. + /// An optional URI fragment. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null. public static string GetPathByRouteValues( this LinkGenerator generator, HttpContext httpContext, @@ -28,6 +41,19 @@ namespace Microsoft.AspNetCore.Routing return generator.GetPathByAddress(httpContext, address, address.ExplicitValues, fragment, options); } + /// + /// Generates a URI with an absolute path based on the provided values. + /// + /// The . + /// The route name. Used to resolve endpoints. Optional. + /// The route values. Used to resolve endpoints and expand parameters in the route template. Optional. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// An optional URI fragment. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null. public static string GetPathByRouteValues( this LinkGenerator generator, string routeName, @@ -45,6 +71,19 @@ namespace Microsoft.AspNetCore.Routing return generator.GetPathByAddress(address, address.ExplicitValues, pathBase, fragment, options); } + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The associated with the current request. + /// The route name. Used to resolve endpoints. Optional. + /// The route values. Used to resolve endpoints and expand parameters in the route template. Optional. + /// An optional URI fragment. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// A URI with an absolute path, or null. public static string GetUriByRouteValues( this LinkGenerator generator, HttpContext httpContext, @@ -62,6 +101,21 @@ namespace Microsoft.AspNetCore.Routing return generator.GetUriByAddress(httpContext, address, address.ExplicitValues, fragment, options); } + /// + /// Generates an absolute URI based on the provided values. + /// + /// The . + /// The route name. Used to resolve endpoints. Optional. + /// The route values. Used to resolve endpoints and expand parameters in the route template. Optional. + /// The URI scheme, applied to the resulting URI. + /// The URI host/authority, applied to the resulting URI. + /// An optional URI path base. Prepended to the path in the resulting URI. + /// An optional URI fragment. Appended to the resulting URI. + /// + /// An optional . Settings on provided object override the settings with matching + /// names from RouteOptions. + /// + /// An absolute URI, or null. public static string GetUriByRouteValues( this LinkGenerator generator, string routeName, @@ -81,7 +135,29 @@ namespace Microsoft.AspNetCore.Routing return generator.GetUriByAddress(address, address.ExplicitValues, scheme, host, pathBase, fragment, options); } + /// + /// Gets a based on the provided and . + /// + /// The . + /// The route name. Used to resolve endpoints. Optional. + /// The route values. Used to resolve endpoints and expand parameters in the route template. Optional. + /// + /// A if one or more endpoints matching the address can be found, otherwise null. + /// + public static LinkGenerationTemplate GetTemplateByRouteValues( + this LinkGenerator generator, + string routeName, + object values) + { + if (generator == null) + { + throw new ArgumentNullException(nameof(generator)); + } + var address = CreateAddress(httpContext: null, routeName, values); + return generator.GetTemplateByAddress(address); + } + private static RouteValuesAddress CreateAddress(HttpContext httpContext, string routeName, object values) { return new RouteValuesAddress() diff --git a/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs index 47c12ae404..72372c4a89 100644 --- a/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs @@ -459,7 +459,7 @@ namespace Microsoft.AspNetCore.Routing => string.Format(CultureInfo.CurrentCulture, GetString("ConstraintMustBeStringOrConstraint"), p0, p1, p2); /// - /// Invalid constraint '{0}'. A constraint must be of type 'string', '{1}', or '{2}'. + /// Invalid constraint '{0}'. A constraint must be of type 'string' or '{1}'. /// internal static string RoutePattern_InvalidConstraintReference { @@ -514,6 +514,34 @@ namespace Microsoft.AspNetCore.Routing internal static string FormatRoutePattern_InvalidStringConstraintReference(object p0, object p1, object p2, object p3) => string.Format(CultureInfo.CurrentCulture, GetString("RoutePattern_InvalidStringConstraintReference"), p0, p1, p2, p3); + /// + /// Endpoints with endpoint name '{0}': + /// + internal static string DuplicateEndpointNameEntry + { + get => GetString("DuplicateEndpointNameEntry"); + } + + /// + /// Endpoints with endpoint name '{0}': + /// + internal static string FormatDuplicateEndpointNameEntry(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("DuplicateEndpointNameEntry"), p0); + + /// + /// The following endpoints with a duplicate endpoint name were found. + /// + internal static string DuplicateEndpointNameHeader + { + get => GetString("DuplicateEndpointNameHeader"); + } + + /// + /// The following endpoints with a duplicate endpoint name were found. + /// + internal static string FormatDuplicateEndpointNameHeader() + => GetString("DuplicateEndpointNameHeader"); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Routing/Resources.resx b/src/Microsoft.AspNetCore.Routing/Resources.resx index c192330770..6ce5d8ff00 100644 --- a/src/Microsoft.AspNetCore.Routing/Resources.resx +++ b/src/Microsoft.AspNetCore.Routing/Resources.resx @@ -225,4 +225,10 @@ Invalid constraint type '{0}' registered as '{1}'. A constraint type must either implement '{2}', or inherit from '{3}'. + + Endpoints with endpoint name '{0}': + + + The following endpoints with a duplicate endpoint name were found. + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Routing/RouteValuesBasedEndpointFinder.cs b/src/Microsoft.AspNetCore.Routing/RouteValuesBasedEndpointFinder.cs index 3ce63cea56..f7d6b77023 100644 --- a/src/Microsoft.AspNetCore.Routing/RouteValuesBasedEndpointFinder.cs +++ b/src/Microsoft.AspNetCore.Routing/RouteValuesBasedEndpointFinder.cs @@ -6,33 +6,27 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing.Internal; -using Microsoft.AspNetCore.Routing.Matching; using Microsoft.AspNetCore.Routing.Template; using Microsoft.AspNetCore.Routing.Tree; -using Microsoft.Extensions.ObjectPool; namespace Microsoft.AspNetCore.Routing { internal class RouteValuesBasedEndpointFinder : IEndpointFinder { - private readonly CompositeEndpointDataSource _endpointDataSource; - private readonly ObjectPool _objectPool; + private readonly CompositeEndpointDataSource _dataSource; private LinkGenerationDecisionTree _allMatchesLinkGenerationTree; private IDictionary> _namedMatchResults; - public RouteValuesBasedEndpointFinder( - CompositeEndpointDataSource endpointDataSource, - ObjectPool objectPool) + public RouteValuesBasedEndpointFinder(CompositeEndpointDataSource dataSource) { - _endpointDataSource = endpointDataSource; - _objectPool = objectPool; + _dataSource = dataSource; // Build initial matches BuildOutboundMatches(); // Register for changes in endpoints Extensions.Primitives.ChangeToken.OnChange( - _endpointDataSource.GetChangeToken, + _dataSource.GetChangeToken, HandleChange); } @@ -68,7 +62,7 @@ namespace Microsoft.AspNetCore.Routing // re-register the callback as the change token is one time use only and a new change token // is produced every time Extensions.Primitives.ChangeToken.OnChange( - _endpointDataSource.GetChangeToken, + _dataSource.GetChangeToken, HandleChange); } @@ -104,7 +98,7 @@ namespace Microsoft.AspNetCore.Routing var namedOutboundMatchResults = new Dictionary>( StringComparer.OrdinalIgnoreCase); - var endpoints = _endpointDataSource.Endpoints.OfType(); + var endpoints = _dataSource.Endpoints.OfType(); foreach (var endpoint in endpoints) { // Do not consider an endpoint for link generation if the following marker metadata is on it diff --git a/test/Microsoft.AspNetCore.Routing.Tests/EndpointNameEndpointFinderTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/EndpointNameEndpointFinderTest.cs new file mode 100644 index 0000000000..9165dce257 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/EndpointNameEndpointFinderTest.cs @@ -0,0 +1,184 @@ +// 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.Http; +using Microsoft.AspNetCore.Routing.TestObjects; +using Xunit; + +namespace Microsoft.AspNetCore.Routing +{ + public class EndpointNameEndpointFinderTest + { + [Fact] + public void EndpointFinder_Match_ReturnsMatchingEndpoint() + { + // Arrange + var endpoint1 = EndpointFactory.CreateRouteEndpoint( + "/a", + metadata: new object[] { new EndpointNameMetadata("name1"), }); + + var endpoint2 = EndpointFactory.CreateRouteEndpoint( + "/b", + metadata: new object[] { new EndpointNameMetadata("name2"), }); + + var finder = CreateEndpointFinder(endpoint1, endpoint2); + + // Act + var endpoints = finder.FindEndpoints("name2"); + + // Assert + Assert.Collection( + endpoints, + e => Assert.Same(endpoint2, e)); + } + + [Fact] + public void EndpointFinder_NoMatch_ReturnsEmptyCollection() + { + // Arrange + var endpoint = EndpointFactory.CreateRouteEndpoint( + "/a", + metadata: new object[] { new EndpointNameMetadata("name1"), new SuppressLinkGenerationMetadata(), }); + + var finder = CreateEndpointFinder(endpoint); + + // Act + var endpoints = finder.FindEndpoints("name2"); + + // Assert + Assert.Empty(endpoints); + } + + [Fact] + public void EndpointFinder_NoMatch_CaseSensitive() + { + // Arrange + var endpoint = EndpointFactory.CreateRouteEndpoint( + "/a", + metadata: new object[] { new EndpointNameMetadata("name1"), new SuppressLinkGenerationMetadata(), }); + + var finder = CreateEndpointFinder(endpoint); + + // Act + var endpoints = finder.FindEndpoints("NAME1"); + + // Assert + Assert.Empty(endpoints); + } + + [Fact] + public void EndpointFinder_UpdatesWhenDataSourceChanges() + { + var endpoint1 = EndpointFactory.CreateRouteEndpoint( + "/a", + metadata: new object[] { new EndpointNameMetadata("name1"), }); + var dynamicDataSource = new DynamicEndpointDataSource(new[] { endpoint1 }); + + // Act 1 + var finder = CreateEndpointFinder(dynamicDataSource); + + // Assert 1 + var match = Assert.Single(finder.Entries); + Assert.Same(endpoint1, match.Value.Single()); + + // Arrange 2 + var endpoint2 = EndpointFactory.CreateRouteEndpoint( + "/b", + metadata: new object[] { new EndpointNameMetadata("name2"), }); + + // Act 2 + // Trigger change + dynamicDataSource.AddEndpoint(endpoint2); + + // Assert 2 + Assert.Collection( + finder.Entries.OrderBy(kvp => kvp.Key), + (m) => + { + Assert.Same(endpoint1, m.Value.Single()); + }, + (m) => + { + Assert.Same(endpoint2, m.Value.Single()); + }); + } + + [Fact] + public void EndpointFinder_IgnoresEndpointsWithSuppressLinkGeneration() + { + // Arrange + var endpoint = EndpointFactory.CreateRouteEndpoint( + "/a", + metadata: new object[] { new EndpointNameMetadata("name1"), new SuppressLinkGenerationMetadata(), }); + + // Act + var finder = CreateEndpointFinder(endpoint); + + // Assert + Assert.Empty(finder.Entries); + } + + [Fact] + public void EndpointFinder_IgnoresEndpointsWithoutEndpointName() + { + // Arrange + var endpoint = EndpointFactory.CreateRouteEndpoint( + "/a", + metadata: new object[] { }); + + // Act + var finder = CreateEndpointFinder(endpoint); + + // Assert + Assert.Empty(finder.Entries); + } + + [Fact] + public void EndpointFinder_ThrowsExceptionForDuplicateEndpoints() + { + // Arrange + var endpoints = new Endpoint[] + { + EndpointFactory.CreateRouteEndpoint("/a", displayName: "a", metadata: new object[] { new EndpointNameMetadata("name1"), }), + EndpointFactory.CreateRouteEndpoint("/b", displayName: "b", metadata: new object[] { new EndpointNameMetadata("name1"), }), + EndpointFactory.CreateRouteEndpoint("/c", displayName: "c", metadata: new object[] { new EndpointNameMetadata("name1"), }), + + //// Not a duplicate + EndpointFactory.CreateRouteEndpoint("/d", displayName: "d", metadata: new object[] { new EndpointNameMetadata("NAME1"), }), + + EndpointFactory.CreateRouteEndpoint("/e", displayName: "e", metadata: new object[] { new EndpointNameMetadata("name2"), }), + EndpointFactory.CreateRouteEndpoint("/f", displayName: "f", metadata: new object[] { new EndpointNameMetadata("name2"), }), + }; + + var finder = CreateEndpointFinder(endpoints); + + // Act + var ex = Assert.Throws(() => finder.FindEndpoints("any name")); + + // Assert + Assert.Equal(@"The following endpoints with a duplicate endpoint name were found. + +Endpoints with endpoint name 'name1': +a +b +c + +Endpoints with endpoint name 'name2': +e +f +", ex.Message); + } + + private EndpointNameEndpointFinder CreateEndpointFinder(params Endpoint[] endpoints) + { + return CreateEndpointFinder(new DefaultEndpointDataSource(endpoints)); + } + + private EndpointNameEndpointFinder CreateEndpointFinder(params EndpointDataSource[] dataSources) + { + return new EndpointNameEndpointFinder(new CompositeEndpointDataSource(dataSources)); + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/LinkGeneratorEndpointNameExtensionsTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/LinkGeneratorEndpointNameExtensionsTest.cs new file mode 100644 index 0000000000..0b57b598c7 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/LinkGeneratorEndpointNameExtensionsTest.cs @@ -0,0 +1,140 @@ +// 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 Microsoft.AspNetCore.Http; +using Xunit; + +namespace Microsoft.AspNetCore.Routing +{ + // Integration tests for GetXyzByName. These are basic because important behavioral details + // are covered elsewhere. + // + // Does not cover template processing in detail, those scenarios are validated by TemplateBinderTests + // and DefaultLinkGeneratorProcessTemplateTest + // + // Does not cover the EndpointNameEndpointFinder in detail. see EndpointNameEndpointFinderTest + public class LinkGeneratorEndpointNameExtensionsTest : LinkGeneratorTestBase + { + [Fact] + public void GetPathByName_WithoutHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = EndpointFactory.CreateRouteEndpoint("some-endpoint/{p}", metadata: new[] { new EndpointNameMetadata("name1"), }); + var endpoint2 = EndpointFactory.CreateRouteEndpoint("some#-other-endpoint/{p}", metadata: new[] { new EndpointNameMetadata("name2"), }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var values = new { p = "In?dex", query = "some?query", }; + + // Act + var path = linkGenerator.GetPathByName( + endpointName: "name2", + values, + new PathString("/Foo/Bar?encodeme?"), + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/some%23-other-endpoint/In%3Fdex/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetPathByName_WithHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = EndpointFactory.CreateRouteEndpoint("some-endpoint/{p}", metadata: new[] { new EndpointNameMetadata("name1"), }); + var endpoint2 = EndpointFactory.CreateRouteEndpoint("some#-other-endpoint/{p}", metadata: new[] { new EndpointNameMetadata("name2"), }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + var values = new { p = "In?dex", query = "some?query", }; + + // Act + var path = linkGenerator.GetPathByName( + httpContext, + endpointName: "name2", + values, + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("/Foo/Bar%3Fencodeme%3F/some%23-other-endpoint/In%3Fdex/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetUriByRouteValues_WithoutHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = EndpointFactory.CreateRouteEndpoint("some-endpoint/{p}", metadata: new[] { new EndpointNameMetadata("name1"), }); + var endpoint2 = EndpointFactory.CreateRouteEndpoint("some#-other-endpoint/{p}", metadata: new[] { new EndpointNameMetadata("name2"), }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var values = new { p = "In?dex", query = "some?query", }; + + // Act + var path = linkGenerator.GetUriByName( + endpointName: "name2", + values, + "http", + new HostString("example.com"), + new PathString("/Foo/Bar?encodeme?"), + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("http://example.com/Foo/Bar%3Fencodeme%3F/some%23-other-endpoint/In%3Fdex/?query=some%3Fquery#Fragment?", path); + } + + [Fact] + public void GetUriByName_WithHttpContext_WithPathBaseAndFragment() + { + // Arrange + var endpoint1 = EndpointFactory.CreateRouteEndpoint("some-endpoint/{p}", metadata: new[] { new EndpointNameMetadata("name1"), }); + var endpoint2 = EndpointFactory.CreateRouteEndpoint("some#-other-endpoint/{p}", metadata: new[] { new EndpointNameMetadata("name2"), }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + var httpContext = CreateHttpContext(); + httpContext.Request.Scheme = "http"; + httpContext.Request.Host = new HostString("example.com"); + httpContext.Request.PathBase = new PathString("/Foo/Bar?encodeme?"); + + var values = new { p = "In?dex", query = "some?query", }; + + // Act + var uri = linkGenerator.GetUriByName( + httpContext, + endpointName: "name2", + values, + new FragmentString("#Fragment?"), + new LinkOptions() { AppendTrailingSlash = true, }); + + // Assert + Assert.Equal("http://example.com/Foo/Bar%3Fencodeme%3F/some%23-other-endpoint/In%3Fdex/?query=some%3Fquery#Fragment?", uri); + } + + [Fact] + public void GetTemplateByName_CreatesTemplate() + { + // Arrange + var endpoint1 = EndpointFactory.CreateRouteEndpoint("some-endpoint/{p}", metadata: new[] { new EndpointNameMetadata("name1"), }); + var endpoint2 = EndpointFactory.CreateRouteEndpoint("some#-other-endpoint/{p}", metadata: new[] { new EndpointNameMetadata("name2"), }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var template = linkGenerator.GetTemplateByName(endpointName: "name2"); + + // Assert + Assert.NotNull(template); + Assert.Collection( + Assert.IsType(template).Endpoints.Cast().OrderBy(e => e.RoutePattern.RawText), + e => Assert.Same(endpoint2, e)); + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/LinkGeneratorRouteValuesAddressExtensionsTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/LinkGeneratorRouteValuesAddressExtensionsTest.cs index d4ccf080d0..0d3d3d5a50 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/LinkGeneratorRouteValuesAddressExtensionsTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/LinkGeneratorRouteValuesAddressExtensionsTest.cs @@ -1,6 +1,7 @@ // 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 Microsoft.AspNetCore.Http; using Xunit; @@ -96,7 +97,7 @@ namespace Microsoft.AspNetCore.Routing } [Fact] - public void GetUri_WithHttpContext_WithPathBaseAndFragment() + public void GetUriByRouteValues_WithHttpContext_WithPathBaseAndFragment() { // Arrange var endpoint1 = EndpointFactory.CreateRouteEndpoint( @@ -124,5 +125,31 @@ namespace Microsoft.AspNetCore.Routing // Assert Assert.Equal("http://example.com/Foo/Bar%3Fencodeme%3F/Home/In%3Fdex/?query=some%3Fquery#Fragment?", uri); } + + [Fact] + public void GetTemplateByRouteValues_CreatesTemplate() + { + // Arrange + var endpoint1 = EndpointFactory.CreateRouteEndpoint( + "{controller}/{action}/{id}", + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "In?dex", })) }); + var endpoint2 = EndpointFactory.CreateRouteEndpoint( + "{controller}/{action}/{id?}", + metadata: new[] { new RouteValuesAddressMetadata(routeName: null, new RouteValueDictionary(new { controller = "Home", action = "In?dex", })) }); + + var linkGenerator = CreateLinkGenerator(endpoint1, endpoint2); + + // Act + var template = linkGenerator.GetTemplateByRouteValues( + routeName: null, + values: new RouteValueDictionary(new { controller = "Home", action = "In?dex", query = "some?query" })); + + // Assert + Assert.NotNull(template); + Assert.Collection( + Assert.IsType(template).Endpoints.Cast().OrderBy(e => e.RoutePattern.RawText), + e => Assert.Same(endpoint2, e), + e => Assert.Same(endpoint1, e)); + } } } diff --git a/test/Microsoft.AspNetCore.Routing.Tests/RouteValueBasedEndpointFinderTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/RouteValueBasedEndpointFinderTest.cs index c3c1f644e8..70c66b052e 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/RouteValueBasedEndpointFinderTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/RouteValueBasedEndpointFinderTest.cs @@ -8,7 +8,6 @@ using Microsoft.AspNetCore.Routing.Internal; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.AspNetCore.Routing.TestObjects; using Microsoft.AspNetCore.Routing.Tree; -using Microsoft.Extensions.ObjectPool; using Xunit; namespace Microsoft.AspNetCore.Routing @@ -83,13 +82,9 @@ namespace Microsoft.AspNetCore.Routing // Arrange 1 var endpoint1 = CreateEndpoint("/a"); var dynamicDataSource = new DynamicEndpointDataSource(new[] { endpoint1 }); - var objectPoolProvider = new DefaultObjectPoolProvider(); - var objectPool = objectPoolProvider.Create(new UriBuilderContextPooledObjectPolicy()); // Act 1 - var finder = new CustomRouteValuesBasedEndpointFinder( - new CompositeEndpointDataSource(new[] { dynamicDataSource }), - objectPool); + var finder = new CustomRouteValuesBasedEndpointFinder(new CompositeEndpointDataSource(new[] { dynamicDataSource })); // Assert 1 Assert.NotNull(finder.AllMatches); @@ -218,14 +213,9 @@ namespace Microsoft.AspNetCore.Routing return CreateEndpointFinder(new DefaultEndpointDataSource(endpoints)); } - private CustomRouteValuesBasedEndpointFinder CreateEndpointFinder(params EndpointDataSource[] endpointDataSources) + private CustomRouteValuesBasedEndpointFinder CreateEndpointFinder(params EndpointDataSource[] dataSources) { - var objectPoolProvider = new DefaultObjectPoolProvider(); - var objectPool = objectPoolProvider.Create(new UriBuilderContextPooledObjectPolicy()); - - return new CustomRouteValuesBasedEndpointFinder( - new CompositeEndpointDataSource(endpointDataSources), - objectPool); + return new CustomRouteValuesBasedEndpointFinder(new CompositeEndpointDataSource(dataSources)); } private RouteEndpoint CreateEndpoint( @@ -256,10 +246,8 @@ namespace Microsoft.AspNetCore.Routing private class CustomRouteValuesBasedEndpointFinder : RouteValuesBasedEndpointFinder { - public CustomRouteValuesBasedEndpointFinder( - CompositeEndpointDataSource endpointDataSource, - ObjectPool objectPool) - : base(endpointDataSource, objectPool) + public CustomRouteValuesBasedEndpointFinder(CompositeEndpointDataSource dataSource) + : base(dataSource) { }