Magical Web API action selector – HTTP-verb and action name dispatching in a single controller

Β· 1566 words Β· 8 minutes to read

If you follow Web API on User Voice or track Web API issues on Codeplex, you’d probably know that one of the most popular requested features of Web API is to allow the developers to combine HTTP verb action dispatching (default one), with action-name based dispatching in a single controller.

The rationale is very obvious, and I’m pretty sure there is not a single Web API developer in the world, who hasn’t run into this problem - by not being allowed to combine these, whenever you want to create a nested resource, you need to add a new controller and manually register a funky nested route to facilitate it.

Let’s create a custom action selector to solve this.

The problem πŸ”—

By default Web API chooses controller actions based on an HTTP Verb (RESTful approach). You can force it into an RPC mode (action name based), but you can’t combine these two in a single controller, and as it would throw an Ambiguous match exception.

Imagine we would like to provide and API with the following URIs (very typical scenario, no?):

/api/customer/  
/api/customer/1  
/api/customer/1/orders  
/api/customer/1/orders/3  
/api/customer/1/orders/3/shipments  
/api/customer/1/orders/3/shipments/1  

In order to achieve this out-of-the box with Web API, you are required to jump through some hoops - create a few separate controllers, and set up several different nested routes manually.

It would be much better if we had this resource, and all of its subresources (since they are part of one logical coherent entity) defined in a single controller, and would need just one default route to facilite the given URI structure.

Building a custom action selector πŸ”—

Normally on the blog, I try to go in details through the code we are writing together, but today’s is slightly more complicated (not very though, ultimately it’s just reflection and some LINQ), so I will just highlight the main points below.

We will create a class that implements IActionSelector, as that would allow us to plug into the hook provided by the Web API under GlobalConfiguration.Configuration.Services.

public class HybridActionSelector : ApiControllerActionSelector  
{  
private readonly IDictionary<ReflectedHttpActionDescriptor, string[]> _actionParams = new Dictionary<ReflectedHttpActionDescriptor, string[]>();

public override HttpActionDescriptor SelectAction(HttpControllerContext controllerContext)  
{  
object actionName, subactionName;  
var hasActionName = controllerContext.RouteData.Values.TryGetValue("action", out actionName);  
var hasSubActionName = controllerContext.RouteData.Values.TryGetValue("subaction", out subactionName); 

var method = controllerContext.Request.Method;  
var allMethods = controllerContext.ControllerDescriptor.ControllerType.GetMethods(BindingFlags.Instance | BindingFlags.Public);  
var validMethods = Array.FindAll(allMethods, IsValidActionMethod);

var actionDescriptors = new HashSet<ReflectedHttpActionDescriptor>();

foreach (var actionDescriptor in validMethods.Select(m => new ReflectedHttpActionDescriptor(controllerContext.ControllerDescriptor, m)))  
{  
actionDescriptors.Add(actionDescriptor);

_actionParams.Add(  
actionDescriptor,  
actionDescriptor.ActionBinding.ParameterBindings  
.Where(b => !b.Descriptor.IsOptional && b.Descriptor.ParameterType.UnderlyingSystemType.IsPrimitive )  
.Select(b => b.Descriptor.Prefix ?? b.Descriptor.ParameterName).ToArray());  
}

IEnumerable<ReflectedHttpActionDescriptor> actionsFoundSoFar;

if (hasSubActionName)  
{  
actionsFoundSoFar =  
actionDescriptors.Where(  
i => i.ActionName.ToLowerInvariant() == subactionName.ToString().ToLowerInvariant() && i.SupportedHttpMethods.Contains(method)).ToArray();  
}  
else if (hasActionName)  
{  
actionsFoundSoFar =  
actionDescriptors.Where(  
i =>  
i.ActionName.ToLowerInvariant() == actionName.ToString().ToLowerInvariant() &&  
i.SupportedHttpMethods.Contains(method)).ToArray();  
}  
else  
{  
actionsFoundSoFar = actionDescriptors.Where(i => i.ActionName.ToLowerInvariant().Contains(method.ToString().ToLowerInvariant()) && i.SupportedHttpMethods.Contains(method)).ToArray();  
}

var actionsFound = FindActionUsingRouteAndQueryParameters(controllerContext, actionsFoundSoFar);

if (actionsFound == null || !actionsFound.Any()) throw new HttpResponseException(controllerContext.Request.CreateErrorResponse(HttpStatusCode.NotFound, "Cannot find a matching action."));  
if (actionsFound.Count() > 1) throw new HttpResponseException(controllerContext.Request.CreateErrorResponse(HttpStatusCode.Ambiguous, "Multiple matches found."));

return actionsFound.FirstOrDefault();  
}

private IEnumerable<ReflectedHttpActionDescriptor> FindActionUsingRouteAndQueryParameters(HttpControllerContext controllerContext, IEnumerable<ReflectedHttpActionDescriptor> actionsFound)  
{  
var routeParameterNames = new HashSet<string>(controllerContext.RouteData.Values.Keys, StringComparer.OrdinalIgnoreCase);

if (routeParameterNames.Contains("controller")) routeParameterNames.Remove("controller");  
if (routeParameterNames.Contains("action")) routeParameterNames.Remove("action");  
if (routeParameterNames.Contains("subaction")) routeParameterNames.Remove("subaction");

var hasQueryParameters = controllerContext.Request.RequestUri != null && !String.IsNullOrEmpty(controllerContext.Request.RequestUri.Query);  
var hasRouteParameters = routeParameterNames.Count != 0;

if (hasRouteParameters || hasQueryParameters)  
{  
var combinedParameterNames = new HashSet<string>(routeParameterNames, StringComparer.OrdinalIgnoreCase);  
if (hasQueryParameters)  
{  
foreach (var queryNameValuePair in controllerContext.Request.GetQueryNameValuePairs())  
{  
combinedParameterNames.Add(queryNameValuePair.Key);  
}  
}

actionsFound = actionsFound.Where(descriptor => _actionParams[descriptor].All(combinedParameterNames.Contains));

if (actionsFound.Count() > 1)  
{  
actionsFound = actionsFound  
.GroupBy(descriptor => _actionParams[descriptor].Length)  
.OrderByDescending(g => g.Key)  
.First();  
}  
}  
else  
{  
actionsFound = actionsFound.Where(descriptor => _actionParams[descriptor].Length == 0);  
}

return actionsFound;  
}

private static bool IsValidActionMethod(MethodInfo methodInfo)  
{  
if (methodInfo.IsSpecialName) return false;  
return !methodInfo.GetBaseDefinition().DeclaringType.IsAssignableFrom(typeof(ApiController));  
}  
}  

So what’s happening here?

  1. We inherit from the default ApiControllerActionSelector and override the virtual SelectAction method.

  2. Then, through reflection, we grab all the methods (“actions”) of a controller class from the controller context, which happen to be valid API actions. At this point we don’t care about verb or action based dispatching yet.

  3. We build a collection of ReflectedHttpActionDescriptor objects for each valid method. This will give us access to such information as what kind of HTTP method a given action supports (this automatically takes care of the HTTP attributes with which the developer might have decorated an action, so we don’t need to worry about that)

  4. We check - from the RouteData - what type of parameters we have in the request query. By default this solution is inteded to support three levels of nesting (as I showed in the route list initially) - so we will check for {action} and {subaction} tokens in the route. If we find a subaction, it means we need to dispatch based on the subaction name, so we try to find a matching method inside the controller (that also supports the current request’s HTTP method!). The same applies for an action, except the order is important - if we have a subaction, it means we don’t need to check for an action anymore.

  5. If neither subaction nor action are found in the route data, it means we will need to try to dispatch based on the HTTP verb. So we try to find a method inside a controller that’s prefixed with the current request’s HTTP method - for example GetAll, Post and so on - so a standard verb based dispatching behaviour.

  6. At this point we probably have found some matching methods, but it is very likely that we have some duplicates - because as you might have noticed - we only checked method names, and supported HTTP verbs, but we didn’t take into account any overloads. So if the developer created overloads, we will have more than 1 match. To work out which is the best one, we run a small helper method, that compares the matches to the route data parameters (I adapted this method from the core Web API) and this should typically yield a single best match.

  7. The final step is to check how many matches we still have. If 0 - throw a 404. If more than 1 - throw an ambiguous match error. If 1 - dispatch the action and let the Web API pipeline continue.

Adding a route πŸ”—

We just said we allow this to go three levels deep (in fact, with some minor changes this could easily support infinite levels of depth, but I’m on the subway now, so will just leave it as it is for now). In order to support that, let’s add a generic route, and remeber that we introduced magical tokens action and subaction.

config.Routes.MapHttpRoute(  
name: "DefaultApi",  
routeTemplate: "api/{controller}/{id}/{action}/{actionid}/{subaction}/{subactionid}",  
defaults: new { id = RouteParameter.Optional, action = RouteParameter.Optional, actionid = RouteParameter.Optional, subaction = RouteParameter.Optional, subactionid = RouteParameter.Optional}  
);  

As you see, we now have a very nice, deep route template, which can support every single of the cases I initially showed.

Now let’s add a controller. It will just return dummy data, but that’s not the point - the idea is to illustrate tht we are hitting what we want to hit.

public class CustomerController :ApiController  
{  
// /api/customer/  
public string Get()  
{  
return "All Customers";  
}

// /api/customer/  
public string Post(Customer c)  
{  
return "Customer with Id: " + c.Id + " added";  
}

// /api/customer/1  
public string GetById(int id)  
{  
return "Customer " + id;  
}

// /api/customer/1/orders  
[HttpGet]  
public string Orders(int id)  
{  
return "All Orders of customer " + id;  
}

// /api/customer/1/orders/3  
[HttpGet]  
public string Orders(int id, int actionid)  
{  
return "Order with id " + actionid + " of customer " + id;  
}

// /api/customer/1/orders  
[HttpPost]  
public string Orders(int id, Order order)  
{  
return "Order with Id: " + order.Id + " of customer "+ id + " added";  
}

// /api/customer/1/orders  
[HttpGet]  
public string Shipments(int id, int actionid)  
{  
return "All shipments of Order with id " + actionid + " of customer " + id;  
}

// /api/customer/1/orders/3/shipments  
[HttpGet]  
public string Shipments(int id, int actionid, int subactionid)  
{  
return "Shipment with Id: " + subactionid + " of order with id " + actionid + " of customer " + id;  
}

// /api/customer/1/orders/3/shipments/1  
[HttpPost]  
public string Shipments(int id, int actionid, Shipment shipment)  
{  
return "Shipment with Id: " + shipment.Id + " of order " + actionid + " of customer " + id + " added";  
}  
}  

Notice - the top level of our resource (customer) will be dispatched based HTTP verb. The lower levels (action/subaction) will use action-based dispatching. Now, if this was using the default action selector, obviously it has no business of workin - we’d run into all kinds of ambiguity errors.

But instead, let’s plug in our hybrid selector and see what happens:

config.Services.Replace(typeof(IHttpActionSelector), new HybridActionSelector());  
  • GET /api/customer/

  • GET /api/customer/1

  • GET /api/customer/1/orders

  • GET /api/customer/1/orders/3

  • GET /api/customer/1/orders/3/shipments

  • GET /api/customer/1/orders/3/shipments/1

  • POST /api/customer

  • POST /api/customer/1/orders

  • POST /api/customer/1/orders/3/shipments

I could keep taking screenshots like this, but clearly - it works.

Summary πŸ”—

Now, one more point before we get all excited. This is not an optimal implementation YET. The main reason is that it resolves the matches on every request, which holds a performance penalty. What should be added to this solution (and what I plan to do, or maybe someone is willing to join forces :)) is caching of the resolved versions. That’s also what Web API core does now internally.

In the meantime, I have put the source code & a demo of this on Github, so feel free to grab it and play around. Cheers!

About


Hi! I'm Filip W., a cloud architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP