File: Routing\AttributeRoute.cs
Web Access
Project: src\src\Mvc\Mvc.Core\src\Microsoft.AspNetCore.Mvc.Core.csproj (Microsoft.AspNetCore.Mvc.Core)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Linq;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Template;
using Microsoft.AspNetCore.Routing.Tree;
using Microsoft.Extensions.DependencyInjection;
using Resources = Microsoft.AspNetCore.Mvc.Core.Resources;
 
namespace Microsoft.AspNetCore.Mvc.Routing;
 
internal sealed class AttributeRoute : IRouter
{
    private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider;
    private readonly IServiceProvider _services;
    private readonly Func<ActionDescriptor[], IRouter> _handlerFactory;
 
    private TreeRouter? _router;
 
    public AttributeRoute(
        IActionDescriptorCollectionProvider actionDescriptorCollectionProvider,
        IServiceProvider services,
        Func<ActionDescriptor[], IRouter> handlerFactory)
    {
        ArgumentNullException.ThrowIfNull(actionDescriptorCollectionProvider);
        ArgumentNullException.ThrowIfNull(services);
        ArgumentNullException.ThrowIfNull(handlerFactory);
 
        _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider;
        _services = services;
        _handlerFactory = handlerFactory;
    }
 
    /// <inheritdoc />
    public VirtualPathData GetVirtualPath(VirtualPathContext context)
    {
        var router = GetTreeRouter();
        return router.GetVirtualPath(context);
    }
 
    /// <inheritdoc />
    public Task RouteAsync(RouteContext context)
    {
        var router = GetTreeRouter();
        return router.RouteAsync(context);
    }
 
    private TreeRouter GetTreeRouter()
    {
        var actions = _actionDescriptorCollectionProvider.ActionDescriptors;
 
        // This is a safe-race. We'll never set router back to null after initializing
        // it on startup.
        if (_router == null || _router.Version != actions.Version)
        {
            var builder = _services.GetRequiredService<TreeRouteBuilder>();
            AddEntries(builder, actions);
            _router = builder.Build(actions.Version);
        }
 
        return _router;
    }
 
    // internal for testing
    internal void AddEntries(TreeRouteBuilder builder, ActionDescriptorCollection actions)
    {
        var routeInfos = GetRouteInfos(actions.Items);
 
        // We're creating one TreeRouteLinkGenerationEntry per action. This allows us to match the intended
        // action by expected route values, and then use the TemplateBinder to generate the link.
        foreach (var routeInfo in routeInfos)
        {
            if (routeInfo.SuppressLinkGeneration)
            {
                continue;
            }
 
            var defaults = new RouteValueDictionary();
            foreach (var kvp in routeInfo.ActionDescriptor.RouteValues)
            {
                defaults.Add(kvp.Key, kvp.Value);
            }
 
            try
            {
                // We use the `NullRouter` as the route handler because we don't need to do anything for link
                // generations. The TreeRouter does it all for us.
                builder.MapOutbound(
                    NullRouter.Instance,
                    routeInfo.RouteTemplate,
                    defaults,
                    routeInfo.RouteName,
                    routeInfo.Order);
            }
            catch (RouteCreationException routeCreationException)
            {
                throw new RouteCreationException(
                    "An error occurred while adding a route to the route builder. " +
                    $"Route name '{routeInfo.RouteName}' and template '{routeInfo.RouteTemplate!.TemplateText}'.",
                    routeCreationException);
            }
        }
 
        // We're creating one AttributeRouteMatchingEntry per group, so we need to identify the distinct set of
        // groups. It's guaranteed that all members of the group have the same template and precedence,
        // so we only need to hang on to a single instance of the RouteInfo for each group.
        var groups = GetInboundRouteGroups(routeInfos);
        foreach (var group in groups)
        {
            var handler = _handlerFactory(group.ToArray());
 
            // Note that because we only support 'inline' defaults, each routeInfo group also has the same
            // set of defaults.
            //
            // We then inject the route group as a default for the matcher so it gets passed back to MVC
            // for use in action selection.
            builder.MapInbound(
                handler,
                group.Key.RouteTemplate,
                group.Key.RouteName,
                group.Key.Order);
        }
    }
 
    private static IEnumerable<IGrouping<RouteInfo, ActionDescriptor>> GetInboundRouteGroups(List<RouteInfo> routeInfos)
    {
        return routeInfos
            .Where(routeInfo => !routeInfo.SuppressPathMatching)
            .GroupBy(r => r, r => r.ActionDescriptor, RouteInfoEqualityComparer.Instance);
    }
 
    private static List<RouteInfo> GetRouteInfos(IReadOnlyList<ActionDescriptor> actions)
    {
        var routeInfos = new List<RouteInfo>();
        var errors = new List<RouteInfo>();
 
        // This keeps a cache of 'Template' objects. It's a fairly common case that multiple actions
        // will use the same route template string; thus, the `Template` object can be shared.
        //
        // For a relatively simple route template, the `Template` object will hold about 500 bytes
        // of memory, so sharing is worthwhile.
        var templateCache = new Dictionary<string, RouteTemplate>(StringComparer.OrdinalIgnoreCase);
 
        var attributeRoutedActions = actions.Where(a => a.AttributeRouteInfo?.Template != null);
        foreach (var action in attributeRoutedActions)
        {
            var routeInfo = GetRouteInfo(templateCache, action);
            if (routeInfo.ErrorMessage == null)
            {
                routeInfos.Add(routeInfo);
            }
            else
            {
                errors.Add(routeInfo);
            }
        }
 
        if (errors.Count > 0)
        {
            var allErrors = string.Join(
                Environment.NewLine + Environment.NewLine,
                errors.Select(
                    e => Resources.FormatAttributeRoute_IndividualErrorMessage(
                        e.ActionDescriptor.DisplayName,
                        Environment.NewLine,
                        e.ErrorMessage)));
 
            var message = Resources.FormatAttributeRoute_AggregateErrorMessage(Environment.NewLine, allErrors);
            throw new RouteCreationException(message);
        }
 
        return routeInfos;
    }
 
    private static RouteInfo GetRouteInfo(
        Dictionary<string, RouteTemplate> templateCache,
        ActionDescriptor action)
    {
        var routeInfo = new RouteInfo()
        {
            ActionDescriptor = action,
        };
 
        try
        {
            var template = action.AttributeRouteInfo!.Template!;
            if (!templateCache.TryGetValue(template, out var parsedTemplate))
            {
                // Parsing with throw if the template is invalid.
                parsedTemplate = TemplateParser.Parse(template);
                templateCache.Add(template, parsedTemplate);
            }
 
            routeInfo.RouteTemplate = parsedTemplate;
            routeInfo.SuppressPathMatching = action.AttributeRouteInfo.SuppressPathMatching;
            routeInfo.SuppressLinkGeneration = action.AttributeRouteInfo.SuppressLinkGeneration;
        }
        catch (Exception ex)
        {
            routeInfo.ErrorMessage = ex.Message;
            return routeInfo;
        }
 
        foreach (var kvp in action.RouteValues)
        {
            foreach (var parameter in routeInfo.RouteTemplate.Parameters)
            {
                if (string.Equals(kvp.Key, parameter.Name, StringComparison.OrdinalIgnoreCase))
                {
                    routeInfo.ErrorMessage = Resources.FormatAttributeRoute_CannotContainParameter(
                        routeInfo.RouteTemplate.TemplateText,
                        kvp.Key,
                        kvp.Value);
 
                    return routeInfo;
                }
            }
        }
 
        routeInfo.Order = action.AttributeRouteInfo.Order;
        routeInfo.RouteName = action.AttributeRouteInfo.Name;
 
        return routeInfo;
    }
 
    private sealed class RouteInfo
    {
        public ActionDescriptor ActionDescriptor { get; init; } = default!;
 
        public string? ErrorMessage { get; set; }
 
        public int Order { get; set; }
 
        public string? RouteName { get; set; }
 
        public RouteTemplate? RouteTemplate { get; set; }
 
        public bool SuppressPathMatching { get; set; }
 
        public bool SuppressLinkGeneration { get; set; }
    }
 
    private sealed class RouteInfoEqualityComparer : IEqualityComparer<RouteInfo>
    {
        public static readonly RouteInfoEqualityComparer Instance = new();
 
        public bool Equals(RouteInfo? x, RouteInfo? y)
        {
            if (x == null && y == null)
            {
                return true;
            }
            else if (x == null || y == null)
            {
                return false;
            }
            else if (x.Order != y.Order)
            {
                return false;
            }
            else
            {
                return string.Equals(
                    x.RouteTemplate!.TemplateText,
                    y.RouteTemplate!.TemplateText,
                    StringComparison.OrdinalIgnoreCase);
            }
        }
 
        public int GetHashCode(RouteInfo obj)
        {
            if (obj == null)
            {
                return 0;
            }
 
            var hash = new HashCode();
            hash.Add(obj.Order);
            hash.Add(obj.RouteTemplate!.TemplateText, StringComparer.OrdinalIgnoreCase);
            return hash.ToHashCode();
        }
    }
 
    // Used only to hook up link generation, and it doesn't need to do anything.
    private sealed class NullRouter : IRouter
    {
        public static readonly NullRouter Instance = new NullRouter();
 
        public VirtualPathData? GetVirtualPath(VirtualPathContext context)
        {
            return null;
        }
 
        public Task RouteAsync(RouteContext context)
        {
            throw new NotImplementedException();
        }
    }
}