// Copyright (c) Microsoft Open Technologies, Inc. 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.Globalization; using System.Linq; using System.Reflection; using Microsoft.AspNet.Mvc.ApplicationModels; using Microsoft.AspNet.Mvc.Core; using Microsoft.AspNet.Mvc.Description; using Microsoft.AspNet.Mvc.Routing; namespace Microsoft.AspNet.Mvc { /// /// Creates instances of from . /// public static class ControllerActionDescriptorBuilder { // This is the default order for attribute routes whose order calculated from // the controller model is null. private const int DefaultAttributeRouteOrder = 0; /// /// Creates instances of from . /// /// The . /// The list of . public static IList Build(ApplicationModel application) { var actions = new List(); var hasAttributeRoutes = false; var removalConstraints = new HashSet(StringComparer.OrdinalIgnoreCase); var methodInfoMap = new MethodToActionMap(); var routeTemplateErrors = new List(); var attributeRoutingConfigurationErrors = new Dictionary(); foreach (var controller in application.Controllers) { foreach (var action in controller.Actions) { // Controllers with multiple [Route] attributes (or user defined implementation of // IRouteTemplateProvider) will generate one action descriptor per IRouteTemplateProvider // instance. // Actions with multiple [Http*] attributes or other (IRouteTemplateProvider implementations // have already been identified as different actions during action discovery. var actionDescriptors = CreateActionDescriptors(application, controller, action); foreach (var actionDescriptor in actionDescriptors) { actionDescriptor.ControllerName = controller.ControllerName; actionDescriptor.ControllerTypeInfo = controller.ControllerType; AddApiExplorerInfo(actionDescriptor, action, controller); AddRouteConstraints(actionDescriptor, controller, action); AddControllerRouteConstraints( actionDescriptor, controller.RouteConstraints, removalConstraints); if (IsAttributeRoutedAction(actionDescriptor)) { hasAttributeRoutes = true; // An attribute routed action will ignore conventional routed constraints. We still // want to provide these values as ambient values for link generation. AddConstraintsAsDefaultRouteValues(actionDescriptor); // Replaces tokens like [controller]/[action] in the route template with the actual values // for this action. ReplaceAttributeRouteTokens(actionDescriptor, routeTemplateErrors); // Attribute routed actions will ignore conventional routed constraints. Instead they have // a single route constraint "RouteGroup" associated with it. ReplaceRouteConstraints(actionDescriptor); } } methodInfoMap.AddToMethodInfo(action, actionDescriptors); actions.AddRange(actionDescriptors); } } var actionsByRouteName = new Dictionary>( StringComparer.OrdinalIgnoreCase); // Keeps track of all the methods that we've validated to avoid visiting each action group // more than once. var validatedMethods = new HashSet(); foreach (var actionDescriptor in actions) { if (!validatedMethods.Contains(actionDescriptor.MethodInfo)) { ValidateActionGroupConfiguration( methodInfoMap, actionDescriptor, attributeRoutingConfigurationErrors); validatedMethods.Add(actionDescriptor.MethodInfo); } if (!IsAttributeRoutedAction(actionDescriptor)) { // Any attribute routes are in use, then non-attribute-routed action descriptors can't be // selected when a route group returned by the route. if (hasAttributeRoutes) { actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( AttributeRouting.RouteGroupKey, string.Empty)); } // Add a route constraint with DenyKey for each constraint in the set to all the // actions that don't have that constraint. For example, if a controller defines // an area constraint, all actions that don't belong to an area must have a route // constraint that prevents them from matching an incomming request. AddRemovalConstraints(actionDescriptor, removalConstraints); } else { var attributeRouteInfo = actionDescriptor.AttributeRouteInfo; if (attributeRouteInfo.Name != null) { // Build a map of attribute route name to action descriptors to ensure that all // attribute routes with a given name have the same template. AddActionToNamedGroup(actionsByRouteName, attributeRouteInfo.Name, actionDescriptor); } // We still want to add a 'null' for any constraint with DenyKey so that link generation // works properly. // // Consider an action like { area = "", controller = "Home", action = "Index" }. Even if // it's attribute routed, it needs to know that area must be null to generate a link. foreach (var key in removalConstraints) { if (!actionDescriptor.RouteValueDefaults.ContainsKey(key)) { actionDescriptor.RouteValueDefaults.Add(key, value: null); } } } } if (attributeRoutingConfigurationErrors.Any()) { var message = CreateAttributeRoutingAggregateErrorMessage( attributeRoutingConfigurationErrors.Values); throw new InvalidOperationException(message); } var namedRoutedErrors = ValidateNamedAttributeRoutedActions(actionsByRouteName); if (namedRoutedErrors.Any()) { var message = CreateAttributeRoutingAggregateErrorMessage(namedRoutedErrors); throw new InvalidOperationException(message); } if (routeTemplateErrors.Any()) { var message = CreateAttributeRoutingAggregateErrorMessage(routeTemplateErrors); throw new InvalidOperationException(message); } return actions; } private static IList CreateActionDescriptors( ApplicationModel application, ControllerModel controller, ActionModel action) { var actionDescriptors = new List(); // We check the action to see if the template allows combination behavior // (It doesn't start with / or ~/) so that in the case where we have multiple // [Route] attributes on the controller we don't end up creating multiple if (action.AttributeRouteModel != null && action.AttributeRouteModel.IsAbsoluteTemplate) { // We're overriding the attribute routes on the controller, so filter out any metadata // from controller level routes. var actionDescriptor = CreateActionDescriptor( action, controllerAttributeRoute: null); actionDescriptors.Add(actionDescriptor); // If we're using an attribute route on the controller, then filter out any additional // metadata from the 'other' attribute routes. var controllerFilters = controller.Filters .Where(c => !(c is IRouteTemplateProvider)); AddActionFilters(actionDescriptor, action.Filters, controllerFilters, application.Filters); var controllerConstraints = controller.ActionConstraints .Where(c => !(c is IRouteTemplateProvider)); AddActionConstraints(actionDescriptor, action, controllerConstraints); } else if (controller.AttributeRoutes != null && controller.AttributeRoutes.Count > 0) { // We're using the attribute routes from the controller foreach (var controllerAttributeRoute in controller.AttributeRoutes) { var actionDescriptor = CreateActionDescriptor( action, controllerAttributeRoute); actionDescriptors.Add(actionDescriptor); // If we're using an attribute route on the controller, then filter out any additional // metadata from the 'other' attribute routes. var controllerFilters = controller.Filters .Where(c => c == controllerAttributeRoute?.Attribute || !(c is IRouteTemplateProvider)); AddActionFilters(actionDescriptor, action.Filters, controllerFilters, application.Filters); var controllerConstraints = controller.ActionConstraints .Where(c => c == controllerAttributeRoute?.Attribute || !(c is IRouteTemplateProvider)); AddActionConstraints(actionDescriptor, action, controllerConstraints); } } else { // No attribute routes on the controller var actionDescriptor = CreateActionDescriptor( action, controllerAttributeRoute: null); actionDescriptors.Add(actionDescriptor); // If there's no attribute route on the controller, then we can use all of the filters/constraints // on the controller. AddActionFilters(actionDescriptor, action.Filters, controller.Filters, application.Filters); AddActionConstraints(actionDescriptor, action, controller.ActionConstraints); } return actionDescriptors; } private static ControllerActionDescriptor CreateActionDescriptor( ActionModel action, AttributeRouteModel controllerAttributeRoute) { var parameterDescriptors = new List(); foreach (var parameter in action.Parameters) { var parameterDescriptor = CreateParameterDescriptor(parameter); parameterDescriptors.Add(parameterDescriptor); } var attributeRouteInfo = CreateAttributeRouteInfo( action.AttributeRouteModel, controllerAttributeRoute); var actionDescriptor = new ControllerActionDescriptor() { Name = action.ActionName, MethodInfo = action.ActionMethod, Parameters = parameterDescriptors, RouteConstraints = new List(), AttributeRouteInfo = attributeRouteInfo, }; actionDescriptor.DisplayName = string.Format( CultureInfo.InvariantCulture, "{0}.{1}", action.ActionMethod.DeclaringType.FullName, action.ActionMethod.Name); return actionDescriptor; } private static ParameterDescriptor CreateParameterDescriptor(ParameterModel parameter) { var parameterDescriptor = new ParameterDescriptor() { BinderMetadata = parameter.BinderMetadata, IsOptional = parameter.IsOptional, Name = parameter.ParameterName, ParameterType = parameter.ParameterInfo.ParameterType, }; return parameterDescriptor; } private static void AddApiExplorerInfo( ControllerActionDescriptor actionDescriptor, ActionModel action, ControllerModel controller) { var apiExplorerIsVisible = action.ApiExplorer?.IsVisible ?? controller.ApiExplorer?.IsVisible ?? false; if (apiExplorerIsVisible) { var apiExplorerActionData = new ApiDescriptionActionData() { GroupName = action.ApiExplorer?.GroupName ?? controller.ApiExplorer?.GroupName, }; actionDescriptor.SetProperty(apiExplorerActionData); } } private static void AddActionFilters( ControllerActionDescriptor actionDescriptor, IEnumerable actionFilters, IEnumerable controllerFilters, IEnumerable globalFilters) { actionDescriptor.FilterDescriptors = actionFilters.Select(f => new FilterDescriptor(f, FilterScope.Action)) .Concat(controllerFilters.Select(f => new FilterDescriptor(f, FilterScope.Controller))) .Concat(globalFilters.Select(f => new FilterDescriptor(f, FilterScope.Global))) .OrderBy(d => d, FilterDescriptorOrderComparer.Comparer) .ToList(); } private static AttributeRouteInfo CreateAttributeRouteInfo( AttributeRouteModel action, AttributeRouteModel controller) { var combinedRoute = AttributeRouteModel.CombineAttributeRouteModel( controller, action); if (combinedRoute == null) { return null; } else { return new AttributeRouteInfo() { Template = combinedRoute.Template, Order = combinedRoute.Order ?? DefaultAttributeRouteOrder, Name = combinedRoute.Name, }; } } private static void AddActionConstraints( ControllerActionDescriptor actionDescriptor, ActionModel action, IEnumerable controllerConstraints) { var constraints = new List(); var httpMethods = action.HttpMethods; if (httpMethods != null && httpMethods.Count > 0) { constraints.Add(new HttpMethodConstraint(httpMethods)); } if (action.ActionConstraints != null) { constraints.AddRange(action.ActionConstraints); } if (controllerConstraints != null) { constraints.AddRange(controllerConstraints); } if (constraints.Count > 0) { actionDescriptor.ActionConstraints = constraints; } } public static void AddRouteConstraints( ControllerActionDescriptor actionDescriptor, ControllerModel controller, ActionModel action) { actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( "controller", controller.ControllerName)); if (action.IsActionNameMatchRequired) { actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( "action", action.ActionName)); } else { actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( "action", string.Empty)); } } private static void AddControllerRouteConstraints( ControllerActionDescriptor actionDescriptor, IList routeconstraints, ISet removalConstraints) { // Apply all the constraints defined on the controller (for example, [Area]) to the actions // in that controller. Also keep track of all the constraints that require preventing actions // without the constraint to match. For example, actions without an [Area] attribute on their // controller should not match when a value has been given for area when matching a url or // generating a link. foreach (var constraintAttribute in routeconstraints) { if (constraintAttribute.BlockNonAttributedActions) { removalConstraints.Add(constraintAttribute.RouteKey); } // Skip duplicates if (!HasConstraint(actionDescriptor.RouteConstraints, constraintAttribute.RouteKey)) { if (constraintAttribute.RouteKeyHandling == RouteKeyHandling.CatchAll) { actionDescriptor.RouteConstraints.Add( RouteDataActionConstraint.CreateCatchAll( constraintAttribute.RouteKey)); } else { actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( constraintAttribute.RouteKey, constraintAttribute.RouteValue)); } } } } private static bool HasConstraint(List constraints, string routeKey) { return constraints.Any( rc => string.Equals(rc.RouteKey, routeKey, StringComparison.OrdinalIgnoreCase)); } private static void ReplaceRouteConstraints(ControllerActionDescriptor actionDescriptor) { var routeGroupValue = GetRouteGroupValue( actionDescriptor.AttributeRouteInfo.Order, actionDescriptor.AttributeRouteInfo.Template); var routeConstraints = new List(); routeConstraints.Add(new RouteDataActionConstraint( AttributeRouting.RouteGroupKey, routeGroupValue)); actionDescriptor.RouteConstraints = routeConstraints; } private static void ReplaceAttributeRouteTokens( ControllerActionDescriptor actionDescriptor, IList routeTemplateErrors) { try { actionDescriptor.AttributeRouteInfo.Template = AttributeRouteModel.ReplaceTokens( actionDescriptor.AttributeRouteInfo.Template, actionDescriptor.RouteValueDefaults); } catch (InvalidOperationException ex) { // Routing will throw an InvalidOperationException here if we can't parse/replace tokens // in the template. var message = Resources.FormatAttributeRoute_IndividualErrorMessage( actionDescriptor.DisplayName, Environment.NewLine, ex.Message); routeTemplateErrors.Add(message); } } private static void AddConstraintsAsDefaultRouteValues(ControllerActionDescriptor actionDescriptor) { foreach (var constraint in actionDescriptor.RouteConstraints) { // We don't need to do anything with attribute routing for 'catch all' behavior. Order // and predecedence of attribute routes allow this kind of behavior. if (constraint.KeyHandling == RouteKeyHandling.RequireKey || constraint.KeyHandling == RouteKeyHandling.DenyKey) { actionDescriptor.RouteValueDefaults.Add(constraint.RouteKey, constraint.RouteValue); } } } private static void AddRemovalConstraints( ControllerActionDescriptor actionDescriptor, ISet removalConstraints) { foreach (var key in removalConstraints) { if (!HasConstraint(actionDescriptor.RouteConstraints, key)) { actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( key, string.Empty)); } } } private static void AddActionToNamedGroup( IDictionary> actionsByRouteName, string routeName, ControllerActionDescriptor actionDescriptor) { IList namedActionGroup; if (actionsByRouteName.TryGetValue(routeName, out namedActionGroup)) { namedActionGroup.Add(actionDescriptor); } else { namedActionGroup = new List(); namedActionGroup.Add(actionDescriptor); actionsByRouteName.Add(routeName, namedActionGroup); } } private static bool IsAttributeRoutedAction(ControllerActionDescriptor actionDescriptor) { return actionDescriptor.AttributeRouteInfo?.Template != null; } private static IList AddErrorNumbers( IEnumerable namedRoutedErrors) { return namedRoutedErrors .Select((error, i) => Resources.FormatAttributeRoute_AggregateErrorMessage_ErrorNumber( i + 1, Environment.NewLine, error)) .ToList(); } private static IList ValidateNamedAttributeRoutedActions( IDictionary> actionsGroupedByRouteName) { var namedRouteErrors = new List(); foreach (var kvp in actionsGroupedByRouteName) { // We are looking for attribute routed actions that have the same name but // different route templates. We pick the first template of the group and // we compare it against the rest of the templates that have that same name // associated. // The moment we find one that is different we report the whole group to the // user in the error message so that he can see the different actions and the // different templates for a given named attribute route. var firstActionDescriptor = kvp.Value[0]; var firstTemplate = firstActionDescriptor.AttributeRouteInfo.Template; for (var i = 1; i < kvp.Value.Count; i++) { var otherActionDescriptor = kvp.Value[i]; var otherActionTemplate = otherActionDescriptor.AttributeRouteInfo.Template; if (!firstTemplate.Equals(otherActionTemplate, StringComparison.OrdinalIgnoreCase)) { var descriptions = kvp.Value.Select(ad => Resources.FormatAttributeRoute_DuplicateNames_Item( ad.DisplayName, ad.AttributeRouteInfo.Template)); var errorDescription = string.Join(Environment.NewLine, descriptions); var message = Resources.FormatAttributeRoute_DuplicateNames( kvp.Key, Environment.NewLine, errorDescription); namedRouteErrors.Add(message); break; } } } return namedRouteErrors; } private static void ValidateActionGroupConfiguration( IDictionary>> methodMap, ControllerActionDescriptor actionDescriptor, IDictionary routingConfigurationErrors) { var hasAttributeRoutedActions = false; var hasConventionallyRoutedActions = false; var actionsForMethod = methodMap[actionDescriptor.MethodInfo]; foreach (var reflectedAction in actionsForMethod) { foreach (var action in reflectedAction.Value) { if (IsAttributeRoutedAction(action)) { hasAttributeRoutedActions = true; } else { hasConventionallyRoutedActions = true; } } } // Validate that no method result in attribute and non attribute actions at the same time. // By design, mixing attribute and conventionally actions in the same method is not allowed. // // This for example: // // [HttpGet] // [HttpPost("Foo")] // public void Foo() { } if (hasAttributeRoutedActions && hasConventionallyRoutedActions) { var message = CreateMixedRoutedActionDescriptorsErrorMessage( actionDescriptor, actionsForMethod); routingConfigurationErrors.Add(actionDescriptor.MethodInfo, message); } } private static string CreateMixedRoutedActionDescriptorsErrorMessage( ControllerActionDescriptor actionDescriptor, IDictionary> actionsForMethod) { // Text to show as the attribute route template for conventionally routed actions. var nullTemplate = Resources.AttributeRoute_NullTemplateRepresentation; var actionDescriptions = new List(); foreach (var action in actionsForMethod.SelectMany(kvp => kvp.Value)) { var routeTemplate = action.AttributeRouteInfo?.Template ?? nullTemplate; var verbs = action.ActionConstraints.OfType().FirstOrDefault()?.HttpMethods; var formattedVerbs = string.Join(", ", verbs.OrderBy(v => v, StringComparer.Ordinal)); var description = Resources.FormatAttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod_Item( action.DisplayName, routeTemplate, formattedVerbs); actionDescriptions.Add(description); } var methodFullName = string.Format( CultureInfo.InvariantCulture, "{0}.{1}", actionDescriptor.MethodInfo.DeclaringType.FullName, actionDescriptor.MethodInfo.Name); // Sample error message: // // A method 'MyApplication.CustomerController.Index' must not define attributed actions and // non attributed actions at the same time: // Action: 'MyApplication.CustomerController.Index' - Route Template: 'Products' - HTTP Verbs: 'PUT' // Action: 'MyApplication.CustomerController.Index' - Route Template: '(none)' - HTTP Verbs: 'POST' // // Use 'AcceptVerbsAttribute' to create a single route that allows multiple HTTP verbs and defines a route, // or set a route template in all attributes that constrain HTTP verbs. return Resources.FormatAttributeRoute_MixedAttributeAndConventionallyRoutedActions_ForMethod( methodFullName, Environment.NewLine, string.Join(Environment.NewLine, actionDescriptions)); } private static string CreateAttributeRoutingAggregateErrorMessage( IEnumerable individualErrors) { var errorMessages = AddErrorNumbers(individualErrors); var message = Resources.FormatAttributeRoute_AggregateErrorMessage( Environment.NewLine, string.Join(Environment.NewLine + Environment.NewLine, errorMessages)); return message; } private static string GetRouteGroupValue(int order, string template) { var group = string.Format(CultureInfo.InvariantCulture, "{0}-{1}", order, template); return ("__route__" + group).ToUpperInvariant(); } // We need to build a map of methods to reflected actions and reflected actions to // action descriptors so that we can validate later that no method produced attribute // and non attributed actions at the same time, and that no method that produced attribute // routed actions has no attributes that implement IActionHttpMethodProvider and do not // implement IRouteTemplateProvider. For example: // // public class ProductsController // { // [HttpGet("Products")] // [HttpPost] // public ActionResult Items(){ ... } // // [HttpGet("Products")] // [CustomHttpMethods("POST, PUT")] // public ActionResult List(){ ... } // } private class MethodToActionMap : Dictionary>> { public void AddToMethodInfo(ActionModel action, IList actionDescriptors) { IDictionary> actionsForMethod = null; if (TryGetValue(action.ActionMethod, out actionsForMethod)) { actionsForMethod.Add(action, actionDescriptors); } else { var reflectedActionMap = new Dictionary>(); reflectedActionMap.Add(action, actionDescriptors); Add(action.ActionMethod, reflectedActionMap); } } } } }