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.Diagnostics.CodeAnalysis;
5 using System.Diagnostics.Contracts;
6 using System.Linq;
7 using System.Net;
8 using System.Net.Http;
9 using System.Reflection;
10 using System.Text;
11 using System.Threading;
12 using System.Web.Http.Internal;
13 using System.Web.Http.Properties;
14 
15 namespace System.Web.Http.Controllers
16 {
17     /// <summary>
18     /// Reflection based action selector.
19     /// We optimize for the case where we have an <see cref="ApiControllerActionSelector"/> instance per <see cref="HttpControllerDescriptor"/>
20     /// instance but can support cases where there are many <see cref="HttpControllerDescriptor"/> instances for one
21     /// <see cref="ApiControllerActionSelector"/> as well. In the latter case the lookup is slightly slower because it goes through
22     /// the <see cref="P:HttpControllerDescriptor.Properties"/> dictionary.
23     /// </summary>
24     public class ApiControllerActionSelector : IHttpActionSelector
25     {
26         private const string ActionRouteKey = "action";
27         private const string ControllerRouteKey = "controller";
28 
29         private ActionSelectorCacheItem _fastCache;
30         private readonly object _cacheKey = new object();
31 
SelectAction(HttpControllerContext controllerContext)32         public virtual HttpActionDescriptor SelectAction(HttpControllerContext controllerContext)
33         {
34             if (controllerContext == null)
35             {
36                 throw Error.ArgumentNull("controllerContext");
37             }
38 
39             ActionSelectorCacheItem internalSelector = GetInternalSelector(controllerContext.ControllerDescriptor);
40             return internalSelector.SelectAction(controllerContext);
41         }
42 
GetActionMapping(HttpControllerDescriptor controllerDescriptor)43         public virtual ILookup<string, HttpActionDescriptor> GetActionMapping(HttpControllerDescriptor controllerDescriptor)
44         {
45             if (controllerDescriptor == null)
46             {
47                 throw Error.ArgumentNull("controllerDescriptor");
48             }
49 
50             ActionSelectorCacheItem internalSelector = GetInternalSelector(controllerDescriptor);
51             return internalSelector.GetActionMapping();
52         }
53 
GetInternalSelector(HttpControllerDescriptor controllerDescriptor)54         private ActionSelectorCacheItem GetInternalSelector(HttpControllerDescriptor controllerDescriptor)
55         {
56             // First check in the local fast cache and if not a match then look in the broader
57             // HttpControllerDescriptor.Properties cache
58             if (_fastCache == null)
59             {
60                 ActionSelectorCacheItem selector = new ActionSelectorCacheItem(controllerDescriptor);
61                 Interlocked.CompareExchange(ref _fastCache, selector, null);
62                 return selector;
63             }
64             else if (_fastCache.HttpControllerDescriptor == controllerDescriptor)
65             {
66                 // If the key matches and we already have the delegate for creating an instance then just execute it
67                 return _fastCache;
68             }
69             else
70             {
71                 // If the key doesn't match then lookup/create delegate in the HttpControllerDescriptor.Properties for
72                 // that HttpControllerDescriptor instance
73                 ActionSelectorCacheItem selector = (ActionSelectorCacheItem)controllerDescriptor.Properties.GetOrAdd(
74                     _cacheKey,
75                     _ => new ActionSelectorCacheItem(controllerDescriptor));
76                 return selector;
77             }
78         }
79 
80         // All caching is in a dedicated cache class, which may be optionally shared across selector instances.
81         // Make this a private nested class so that nobody else can conflict with our state.
82         // Cache is initialized during ctor on a single thread.
83         private class ActionSelectorCacheItem
84         {
85             private readonly HttpControllerDescriptor _controllerDescriptor;
86 
87             private readonly ReflectedHttpActionDescriptor[] _actionDescriptors;
88 
89             private readonly IDictionary<ReflectedHttpActionDescriptor, IEnumerable<string>> _actionParameterNames = new Dictionary<ReflectedHttpActionDescriptor, IEnumerable<string>>();
90 
91             private readonly ILookup<string, ReflectedHttpActionDescriptor> _actionNameMapping;
92 
93             // Selection commonly looks up an action by verb.
94             // Cache this mapping. These caches are completely optional and we still behave correctly if we cache miss.
95             // We can adjust the specific set we cache based on profiler information.
96             // Conceptually, this set of caches could be a HttpMethod --> ReflectedHttpActionDescriptor[].
97             // - Beware that HttpMethod has a very slow hash function (it does case-insensitive string hashing). So don't use Dict.
98             // - there are unbounded number of http methods, so make sure the cache doesn't grow indefinitely.
99             // - we can build the cache at startup and don't need to continually add to it.
100             private readonly HttpMethod[] _cacheListVerbKinds = new HttpMethod[] { HttpMethod.Get, HttpMethod.Put, HttpMethod.Post };
101             private readonly ReflectedHttpActionDescriptor[][] _cacheListVerbs;
102 
ActionSelectorCacheItem(HttpControllerDescriptor controllerDescriptor)103             public ActionSelectorCacheItem(HttpControllerDescriptor controllerDescriptor)
104             {
105                 Contract.Assert(controllerDescriptor != null);
106 
107                 // Initialize the cache entirely in the ctor on a single thread.
108                 _controllerDescriptor = controllerDescriptor;
109 
110                 MethodInfo[] allMethods = _controllerDescriptor.ControllerType.GetMethods(BindingFlags.Instance | BindingFlags.Public);
111                 MethodInfo[] validMethods = Array.FindAll(allMethods, IsValidActionMethod);
112 
113                 _actionDescriptors = new ReflectedHttpActionDescriptor[validMethods.Length];
114                 for (int i = 0; i < validMethods.Length; i++)
115                 {
116                     MethodInfo method = validMethods[i];
117                     ReflectedHttpActionDescriptor actionDescriptor = new ReflectedHttpActionDescriptor(_controllerDescriptor, method);
118                     _actionDescriptors[i] = actionDescriptor;
119                     HttpActionBinding actionBinding = controllerDescriptor.ActionValueBinder.GetBinding(actionDescriptor);
120 
121                     // Build action parameter name mapping, only consider parameters that are simple types, do not have default values and come from URI
122                     _actionParameterNames.Add(
123                         actionDescriptor,
124                         actionBinding.ParameterBindings
125                             .Where(binding => TypeHelper.IsSimpleUnderlyingType(binding.Descriptor.ParameterType) && !binding.HasDefaultValue() && binding.WillReadUri())
126                             .Select(binding => binding.Descriptor.Prefix ?? binding.Descriptor.ParameterName));
127                 }
128 
129                 _actionNameMapping = _actionDescriptors.ToLookup(actionDesc => actionDesc.ActionName, StringComparer.OrdinalIgnoreCase);
130 
131                 // Bucket the action descriptors by common verbs.
132                 int len = _cacheListVerbKinds.Length;
133                 _cacheListVerbs = new ReflectedHttpActionDescriptor[len][];
134                 for (int i = 0; i < len; i++)
135                 {
136                     _cacheListVerbs[i] = FindActionsForVerbWorker(_cacheListVerbKinds[i]);
137                 }
138             }
139 
140             public HttpControllerDescriptor HttpControllerDescriptor
141             {
142                 get { return _controllerDescriptor; }
143             }
144 
145             [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "Caller is responsible for disposing of response instance.")]
SelectAction(HttpControllerContext controllerContext)146             public HttpActionDescriptor SelectAction(HttpControllerContext controllerContext)
147             {
148                 string actionName;
149                 bool useActionName = controllerContext.RouteData.Values.TryGetValue(ActionRouteKey, out actionName);
150 
151                 ReflectedHttpActionDescriptor[] actionsFoundByHttpMethods;
152 
153                 HttpMethod incomingMethod = controllerContext.Request.Method;
154 
155                 // First get an initial candidate list.
156                 if (useActionName)
157                 {
158                     // We have an explicit {action} value, do traditional binding. Just lookup by actionName
159                     ReflectedHttpActionDescriptor[] actionsFoundByName = _actionNameMapping[actionName].ToArray();
160 
161                     // Throws HttpResponseException with NotFound status because no action matches the Name
162                     if (actionsFoundByName.Length == 0)
163                     {
164                         throw new HttpResponseException(controllerContext.Request.CreateResponse(
165                             HttpStatusCode.NotFound,
166                             Error.Format(SRResources.ApiControllerActionSelector_ActionNameNotFound, _controllerDescriptor.ControllerName, actionName)));
167                     }
168 
169                     // This filters out any incompatible verbs from the incoming action list
170                     actionsFoundByHttpMethods = actionsFoundByName.Where(actionDescriptor => actionDescriptor.SupportedHttpMethods.Contains(incomingMethod)).ToArray();
171                 }
172                 else
173                 {
174                     // No {action} parameter, infer it from the verb.
175                     actionsFoundByHttpMethods = FindActionsForVerb(incomingMethod);
176                 }
177 
178                 // Throws HttpResponseException with MethodNotAllowed status because no action matches the Http Method
179                 if (actionsFoundByHttpMethods.Length == 0)
180                 {
181                     throw new HttpResponseException(controllerContext.Request.CreateResponse(
182                         HttpStatusCode.MethodNotAllowed,
183                         Error.Format(SRResources.ApiControllerActionSelector_HttpMethodNotSupported, incomingMethod)));
184                 }
185 
186                 // If there are multiple candidates, then apply overload resolution logic.
187                 if (actionsFoundByHttpMethods.Length > 1)
188                 {
189                     actionsFoundByHttpMethods = FindActionUsingRouteAndQueryParameters(controllerContext, actionsFoundByHttpMethods).ToArray();
190                 }
191 
192                 List<ReflectedHttpActionDescriptor> selectedActions = RunSelectionFilters(controllerContext, actionsFoundByHttpMethods);
193                 actionsFoundByHttpMethods = null;
194 
195                 switch (selectedActions.Count)
196                 {
197                     case 0:
198                         // Throws HttpResponseException with NotFound status because no action matches the request
199                         throw new HttpResponseException(controllerContext.Request.CreateResponse(
200                             HttpStatusCode.NotFound,
201                             Error.Format(SRResources.ApiControllerActionSelector_ActionNotFound, _controllerDescriptor.ControllerName)));
202                     case 1:
203                         return selectedActions[0];
204                     default:
205                         // Throws HttpResponseException with InternalServerError status because multiple action matches the request
206                         string ambiguityList = CreateAmbiguousMatchList(selectedActions);
207                         throw new HttpResponseException(controllerContext.Request.CreateResponse(
208                             HttpStatusCode.InternalServerError,
209                             Error.Format(SRResources.ApiControllerActionSelector_AmbiguousMatch, ambiguityList)));
210                 }
211             }
212 
GetActionMapping()213             public ILookup<string, HttpActionDescriptor> GetActionMapping()
214             {
215                 return new LookupAdapter() { Source = _actionNameMapping };
216             }
217 
FindActionUsingRouteAndQueryParameters(HttpControllerContext controllerContext, IEnumerable<ReflectedHttpActionDescriptor> actionsFound)218             private IEnumerable<ReflectedHttpActionDescriptor> FindActionUsingRouteAndQueryParameters(HttpControllerContext controllerContext, IEnumerable<ReflectedHttpActionDescriptor> actionsFound)
219             {
220                 // TODO, DevDiv 320655, improve performance of this method.
221                 IDictionary<string, object> routeValues = controllerContext.RouteData.Values;
222                 IEnumerable<string> routeParameterNames = routeValues.Select(route => route.Key)
223                     .Where(key =>
224                            !String.Equals(key, ControllerRouteKey, StringComparison.OrdinalIgnoreCase) &&
225                            !String.Equals(key, ActionRouteKey, StringComparison.OrdinalIgnoreCase));
226 
227                 IEnumerable<string> queryParameterNames = controllerContext.Request.RequestUri.ParseQueryString().AllKeys;
228                 bool hasRouteParameters = routeParameterNames.Any();
229                 bool hasQueryParameters = queryParameterNames.Any();
230 
231                 if (hasRouteParameters || hasQueryParameters)
232                 {
233                     // refine the results based on route parameters to make sure that route parameters take precedence over query parameters
234                     if (hasRouteParameters && hasQueryParameters)
235                     {
236                         // route parameters is a subset of action parameters
237                         actionsFound = actionsFound.Where(descriptor => !routeParameterNames.Except(_actionParameterNames[descriptor], StringComparer.OrdinalIgnoreCase).Any());
238                     }
239 
240                     // further refine the results making sure that action parameters is a subset of route parameters and query parameters
241                     if (actionsFound.Count() > 1)
242                     {
243                         IEnumerable<string> combinedParameterNames = queryParameterNames.Union(routeParameterNames);
244 
245                         // action parameters is a subset of route parameters and query parameters
246                         actionsFound = actionsFound.Where(descriptor => !_actionParameterNames[descriptor].Except(combinedParameterNames, StringComparer.OrdinalIgnoreCase).Any());
247 
248                         // select the results with the longest parameter match
249                         if (actionsFound.Count() > 1)
250                         {
251                             actionsFound = actionsFound
252                                 .GroupBy(descriptor => _actionParameterNames[descriptor].Count())
253                                 .OrderByDescending(g => g.Key)
254                                 .First();
255                         }
256                     }
257                 }
258                 else
259                 {
260                     // return actions with no parameters
261                     actionsFound = actionsFound.Where(descriptor => !_actionParameterNames[descriptor].Any());
262                 }
263 
264                 return actionsFound;
265             }
266 
RunSelectionFilters(HttpControllerContext controllerContext, IEnumerable<HttpActionDescriptor> descriptorsFound)267             private static List<ReflectedHttpActionDescriptor> RunSelectionFilters(HttpControllerContext controllerContext, IEnumerable<HttpActionDescriptor> descriptorsFound)
268             {
269                 // remove all methods which are opting out of this request
270                 // to opt out, at least one attribute defined on the method must return false
271 
272                 List<ReflectedHttpActionDescriptor> matchesWithSelectionAttributes = null;
273                 List<ReflectedHttpActionDescriptor> matchesWithoutSelectionAttributes = new List<ReflectedHttpActionDescriptor>();
274 
275                 foreach (ReflectedHttpActionDescriptor actionDescriptor in descriptorsFound)
276                 {
277                     IActionMethodSelector[] attrs = actionDescriptor.CacheAttrsIActionMethodSelector;
278                     if (attrs.Length == 0)
279                     {
280                         matchesWithoutSelectionAttributes.Add(actionDescriptor);
281                     }
282                     else
283                     {
284                         bool match = Array.TrueForAll(attrs, selector => selector.IsValidForRequest(controllerContext, actionDescriptor.MethodInfo));
285                         if (match)
286                         {
287                             if (matchesWithSelectionAttributes == null)
288                             {
289                                 matchesWithSelectionAttributes = new List<ReflectedHttpActionDescriptor>();
290                             }
291                             matchesWithSelectionAttributes.Add(actionDescriptor);
292                         }
293                     }
294                 }
295 
296                 // if a matching action method had a selection attribute, consider it more specific than a matching action method
297                 // without a selection attribute
298                 if ((matchesWithSelectionAttributes != null) && (matchesWithSelectionAttributes.Count > 0))
299                 {
300                     return matchesWithSelectionAttributes;
301                 }
302                 else
303                 {
304                     return matchesWithoutSelectionAttributes;
305                 }
306             }
307 
308             // This is called when we don't specify an Action name
309             // Get list of actions that match a given verb. This can match by name or IActionHttpMethodSelecto
FindActionsForVerb(HttpMethod verb)310             private ReflectedHttpActionDescriptor[] FindActionsForVerb(HttpMethod verb)
311             {
312                 // Check cache for common verbs.
313                 for (int i = 0; i < _cacheListVerbKinds.Length; i++)
314                 {
315                     // verb selection on common verbs is normalized to have object reference identity.
316                     // This is significantly more efficient than comparing the verbs based on strings.
317                     if (Object.ReferenceEquals(verb, _cacheListVerbKinds[i]))
318                     {
319                         return _cacheListVerbs[i];
320                     }
321                 }
322 
323                 // General case for any verbs.
324                 return FindActionsForVerbWorker(verb);
325             }
326 
327             // This is called when we don't specify an Action name
328             // Get list of actions that match a given verb. This can match by name or IActionHttpMethodSelector.
329             // Since this list is fixed for a given verb type, it can be pre-computed and cached.
330             // This function should not do caching. It's the helper that builds the caches.
FindActionsForVerbWorker(HttpMethod verb)331             private ReflectedHttpActionDescriptor[] FindActionsForVerbWorker(HttpMethod verb)
332             {
333                 List<ReflectedHttpActionDescriptor> listMethods = new List<ReflectedHttpActionDescriptor>();
334 
335                 foreach (ReflectedHttpActionDescriptor descriptor in _actionDescriptors)
336                 {
337                     if (descriptor.SupportedHttpMethods.Contains(verb))
338                     {
339                         listMethods.Add(descriptor);
340                     }
341                 }
342 
343                 return listMethods.ToArray();
344             }
345 
CreateAmbiguousMatchList(IEnumerable<HttpActionDescriptor> ambiguousDescriptors)346             private static string CreateAmbiguousMatchList(IEnumerable<HttpActionDescriptor> ambiguousDescriptors)
347             {
348                 StringBuilder exceptionMessageBuilder = new StringBuilder();
349                 foreach (ReflectedHttpActionDescriptor descriptor in ambiguousDescriptors)
350                 {
351                     MethodInfo methodInfo = descriptor.MethodInfo;
352 
353                     exceptionMessageBuilder.AppendLine();
354                     exceptionMessageBuilder.Append(Error.Format(
355                         SRResources.ActionSelector_AmbiguousMatchType,
356                         methodInfo, methodInfo.DeclaringType.FullName));
357                 }
358 
359                 return exceptionMessageBuilder.ToString();
360             }
361 
IsValidActionMethod(MethodInfo methodInfo)362             private static bool IsValidActionMethod(MethodInfo methodInfo)
363             {
364                 if (methodInfo.IsSpecialName)
365                 {
366                     // not a normal method, e.g. a constructor or an event
367                     return false;
368                 }
369 
370                 if (methodInfo.GetBaseDefinition().DeclaringType.IsAssignableFrom(TypeHelper.ApiControllerType))
371                 {
372                     // is a method on Object, IHttpController, ApiController
373                     return false;
374                 }
375 
376                 return true;
377             }
378         }
379 
380         // We need to expose ILookup<string, HttpActionDescriptor>, but we have a ILookup<string, ReflectedHttpActionDescriptor>
381         // ReflectedHttpActionDescriptor derives from HttpActionDescriptor, but ILookup doesn't support Covariance.
382         // Adapter class since ILookup doesn't support Covariance.
383         // Fortunately, IGrouping, IEnumerable support Covariance, so it's easy to forward.
384         private class LookupAdapter : ILookup<string, HttpActionDescriptor>
385         {
386             public ILookup<string, ReflectedHttpActionDescriptor> Source;
387 
388             public int Count
389             {
390                 get { return Source.Count; }
391             }
392 
393             public IEnumerable<HttpActionDescriptor> this[string key]
394             {
395                 get { return Source[key]; }
396             }
397 
Contains(string key)398             public bool Contains(string key)
399             {
400                 return Source.Contains(key);
401             }
402 
GetEnumerator()403             public IEnumerator<IGrouping<string, HttpActionDescriptor>> GetEnumerator()
404             {
405                 return Source.GetEnumerator();
406             }
407 
Collections.IEnumerable.GetEnumerator()408             Collections.IEnumerator Collections.IEnumerable.GetEnumerator()
409             {
410                 return Source.GetEnumerator();
411             }
412         }
413     }
414 }
415