1 // Copyright (c) Microsoft Corporation. All rights reserved. See License.txt in the project root for license information.
2 
3 using System.Collections.Generic;
4 using System.Globalization;
5 using System.Linq;
6 using System.Reflection;
7 using System.Text;
8 using System.Threading.Tasks;
9 using System.Web.Mvc.Properties;
10 
11 namespace System.Web.Mvc.Async
12 {
13     internal sealed class AsyncActionMethodSelector
14     {
15         // This flag controls async action binding for backwards compat since Controller now supports async.
16         // Set to true for classes that derive from AsyncController. In this case, FooAsync/FooCompleted is
17         // bound as a single async action pair "Foo". If false, they're bound as 2 separate sync actions.
18         // Practically, if this is false, then IsAsyncSuffixedMethod and IsCompeltedSuffixedMethod return false.
19         private bool _allowLegacyAsyncActions;
20 
AsyncActionMethodSelector(Type controllerType, bool allowLegacyAsyncActions = true)21         public AsyncActionMethodSelector(Type controllerType, bool allowLegacyAsyncActions = true)
22         {
23             _allowLegacyAsyncActions = allowLegacyAsyncActions;
24             ControllerType = controllerType;
25             PopulateLookupTables();
26         }
27 
28         public Type ControllerType { get; private set; }
29 
30         public MethodInfo[] AliasedMethods { get; private set; }
31 
32         public ILookup<string, MethodInfo> NonAliasedMethods { get; private set; }
33 
CreateAmbiguousActionMatchException(IEnumerable<MethodInfo> ambiguousMethods, string actionName)34         private AmbiguousMatchException CreateAmbiguousActionMatchException(IEnumerable<MethodInfo> ambiguousMethods, string actionName)
35         {
36             string ambiguityList = CreateAmbiguousMatchList(ambiguousMethods);
37             string message = String.Format(CultureInfo.CurrentCulture, MvcResources.ActionMethodSelector_AmbiguousMatch,
38                                            actionName, ControllerType.Name, ambiguityList);
39             return new AmbiguousMatchException(message);
40         }
41 
CreateAmbiguousMethodMatchException(IEnumerable<MethodInfo> ambiguousMethods, string methodName)42         private AmbiguousMatchException CreateAmbiguousMethodMatchException(IEnumerable<MethodInfo> ambiguousMethods, string methodName)
43         {
44             string ambiguityList = CreateAmbiguousMatchList(ambiguousMethods);
45             string message = String.Format(CultureInfo.CurrentCulture, MvcResources.AsyncActionMethodSelector_AmbiguousMethodMatch,
46                                            methodName, ControllerType.Name, ambiguityList);
47             return new AmbiguousMatchException(message);
48         }
49 
CreateAmbiguousMatchList(IEnumerable<MethodInfo> ambiguousMethods)50         private static string CreateAmbiguousMatchList(IEnumerable<MethodInfo> ambiguousMethods)
51         {
52             StringBuilder exceptionMessageBuilder = new StringBuilder();
53             foreach (MethodInfo methodInfo in ambiguousMethods)
54             {
55                 exceptionMessageBuilder.AppendLine();
56                 exceptionMessageBuilder.AppendFormat(CultureInfo.CurrentCulture, MvcResources.ActionMethodSelector_AmbiguousMatchType, methodInfo, methodInfo.DeclaringType.FullName);
57             }
58 
59             return exceptionMessageBuilder.ToString();
60         }
61 
FindAction(ControllerContext controllerContext, string actionName)62         public ActionDescriptorCreator FindAction(ControllerContext controllerContext, string actionName)
63         {
64             List<MethodInfo> methodsMatchingName = GetMatchingAliasedMethods(controllerContext, actionName);
65             methodsMatchingName.AddRange(NonAliasedMethods[actionName]);
66             List<MethodInfo> finalMethods = RunSelectionFilters(controllerContext, methodsMatchingName);
67 
68             switch (finalMethods.Count)
69             {
70                 case 0:
71                     return null;
72 
73                 case 1:
74                     MethodInfo entryMethod = finalMethods[0];
75                     return GetActionDescriptorDelegate(entryMethod);
76 
77                 default:
78                     throw CreateAmbiguousActionMatchException(finalMethods, actionName);
79             }
80         }
81 
GetActionDescriptorDelegate(MethodInfo entryMethod)82         private ActionDescriptorCreator GetActionDescriptorDelegate(MethodInfo entryMethod)
83         {
84             // Does the action return a Task?
85             if (entryMethod.ReturnType != null && typeof(Task).IsAssignableFrom(entryMethod.ReturnType))
86             {
87                 return (actionName, controllerDescriptor) => new TaskAsyncActionDescriptor(entryMethod, actionName, controllerDescriptor);
88             }
89 
90             // Is this the FooAsync() / FooCompleted() pattern?
91             if (IsAsyncSuffixedMethod(entryMethod))
92             {
93                 string completionMethodName = entryMethod.Name.Substring(0, entryMethod.Name.Length - "Async".Length) + "Completed";
94                 MethodInfo completionMethod = GetMethodByName(completionMethodName);
95                 if (completionMethod != null)
96                 {
97                     return (actionName, controllerDescriptor) => new ReflectedAsyncActionDescriptor(entryMethod, completionMethod, actionName, controllerDescriptor);
98                 }
99                 else
100                 {
101                     throw Error.AsyncActionMethodSelector_CouldNotFindMethod(completionMethodName, ControllerType);
102                 }
103             }
104 
105             // Fallback to synchronous method
106             return (actionName, controllerDescriptor) => new ReflectedActionDescriptor(entryMethod, actionName, controllerDescriptor);
107         }
108 
GetCanonicalMethodName(MethodInfo methodInfo)109         private string GetCanonicalMethodName(MethodInfo methodInfo)
110         {
111             string methodName = methodInfo.Name;
112             return (IsAsyncSuffixedMethod(methodInfo))
113                        ? methodName.Substring(0, methodName.Length - "Async".Length)
114                        : methodName;
115         }
116 
GetMatchingAliasedMethods(ControllerContext controllerContext, string actionName)117         internal List<MethodInfo> GetMatchingAliasedMethods(ControllerContext controllerContext, string actionName)
118         {
119             // find all aliased methods which are opting in to this request
120             // to opt in, all attributes defined on the method must return true
121 
122             var methods = from methodInfo in AliasedMethods
123                           let attrs = ReflectedAttributeCache.GetActionNameSelectorAttributes(methodInfo)
124                           where attrs.All(attr => attr.IsValidName(controllerContext, actionName, methodInfo))
125                           select methodInfo;
126             return methods.ToList();
127         }
128 
IsAsyncSuffixedMethod(MethodInfo methodInfo)129         private bool IsAsyncSuffixedMethod(MethodInfo methodInfo)
130         {
131             return _allowLegacyAsyncActions && methodInfo.Name.EndsWith("Async", StringComparison.OrdinalIgnoreCase);
132         }
133 
IsCompletedSuffixedMethod(MethodInfo methodInfo)134         private bool IsCompletedSuffixedMethod(MethodInfo methodInfo)
135         {
136             return _allowLegacyAsyncActions && methodInfo.Name.EndsWith("Completed", StringComparison.OrdinalIgnoreCase);
137         }
138 
IsMethodDecoratedWithAliasingAttribute(MethodInfo methodInfo)139         private static bool IsMethodDecoratedWithAliasingAttribute(MethodInfo methodInfo)
140         {
141             return methodInfo.IsDefined(typeof(ActionNameSelectorAttribute), true /* inherit */);
142         }
143 
GetMethodByName(string methodName)144         private MethodInfo GetMethodByName(string methodName)
145         {
146             List<MethodInfo> methods = (from MethodInfo methodInfo in ControllerType.GetMember(methodName, MemberTypes.Method, BindingFlags.Instance | BindingFlags.Public | BindingFlags.InvokeMethod | BindingFlags.IgnoreCase)
147                                         where IsValidActionMethod(methodInfo, false /* stripInfrastructureMethods */)
148                                         select methodInfo).ToList();
149 
150             switch (methods.Count)
151             {
152                 case 0:
153                     return null;
154 
155                 case 1:
156                     return methods[0];
157 
158                 default:
159                     throw CreateAmbiguousMethodMatchException(methods, methodName);
160             }
161         }
162 
IsValidActionMethod(MethodInfo methodInfo)163         private bool IsValidActionMethod(MethodInfo methodInfo)
164         {
165             return IsValidActionMethod(methodInfo, true /* stripInfrastructureMethods */);
166         }
167 
IsValidActionMethod(MethodInfo methodInfo, bool stripInfrastructureMethods)168         private bool IsValidActionMethod(MethodInfo methodInfo, bool stripInfrastructureMethods)
169         {
170             if (methodInfo.IsSpecialName)
171             {
172                 // not a normal method, e.g. a constructor or an event
173                 return false;
174             }
175 
176             if (methodInfo.GetBaseDefinition().DeclaringType.IsAssignableFrom(typeof(AsyncController)))
177             {
178                 // is a method on Object, ControllerBase, Controller, or AsyncController
179                 return false;
180             }
181 
182             if (stripInfrastructureMethods)
183             {
184                 if (IsCompletedSuffixedMethod(methodInfo))
185                 {
186                     // do not match FooCompleted() methods, as these are infrastructure methods
187                     return false;
188                 }
189             }
190 
191             return true;
192         }
193 
PopulateLookupTables()194         private void PopulateLookupTables()
195         {
196             MethodInfo[] allMethods = ControllerType.GetMethods(BindingFlags.InvokeMethod | BindingFlags.Instance | BindingFlags.Public);
197             MethodInfo[] actionMethods = Array.FindAll(allMethods, IsValidActionMethod);
198 
199             AliasedMethods = Array.FindAll(actionMethods, IsMethodDecoratedWithAliasingAttribute);
200             NonAliasedMethods = actionMethods.Except(AliasedMethods).ToLookup(GetCanonicalMethodName, StringComparer.OrdinalIgnoreCase);
201         }
202 
RunSelectionFilters(ControllerContext controllerContext, List<MethodInfo> methodInfos)203         private static List<MethodInfo> RunSelectionFilters(ControllerContext controllerContext, List<MethodInfo> methodInfos)
204         {
205             // remove all methods which are opting out of this request
206             // to opt out, at least one attribute defined on the method must return false
207 
208             List<MethodInfo> matchesWithSelectionAttributes = new List<MethodInfo>();
209             List<MethodInfo> matchesWithoutSelectionAttributes = new List<MethodInfo>();
210 
211             foreach (MethodInfo methodInfo in methodInfos)
212             {
213                 ICollection<ActionMethodSelectorAttribute> attrs = ReflectedAttributeCache.GetActionMethodSelectorAttributes(methodInfo);
214                 if (attrs.Count == 0)
215                 {
216                     matchesWithoutSelectionAttributes.Add(methodInfo);
217                 }
218                 else if (attrs.All(attr => attr.IsValidForRequest(controllerContext, methodInfo)))
219                 {
220                     matchesWithSelectionAttributes.Add(methodInfo);
221                 }
222             }
223 
224             // if a matching action method had a selection attribute, consider it more specific than a matching action method
225             // without a selection attribute
226             return (matchesWithSelectionAttributes.Count > 0) ? matchesWithSelectionAttributes : matchesWithoutSelectionAttributes;
227         }
228     }
229 }
230