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