2015-11-11

Routing to different actions based on posted json values in Web API 2

Background and context

I was working on a RESTful web service whose purpose, somewhat simplified, is to let clients create, handle, browse and search for Subject objects (which are pretty much strings bundled with some metadata). These are some examples from the service:

GET /api/subjects (List all Subjects)
GET /api/subjects/<id> (Get a specific Subject)
GET /api/subjects/<id>/translations (List a specific subject and all its translations)
...
POST /api/subjects (Create a Subject from posted json)

I ended up in a situation where I wanted implementing clients to be able to POST several types of json objects to a single route. Creating a Subject was already implemented according to the above list of routes but now I also wanted the following:

POST /api/subjects (Get a list of subjects according to a Query object from posted json)

Problem?

Web API figures out what to do with a request using a IHttpActionSelector. The default implementation is ApiControllerActionSelector and it basically picks the action with the most matching route and query parameters. Running it can result in three outcomes:
  • No suitable action found
  • Finds the most suitable action
  • Finds more than one most suitable actions, ambiguity

The problem is that ApiControllerActionSelector does not care about the body of a post (which is where json resides in the request object when it is posted). Therefore, adding two HttpPost actions to the same route will result in ambiguity even though they have different parameter types.

A custom IHttpActionSelector

I wanted to extend ApiControllerActionSelector (which implements IHttpActionSelector) so that the json body of post requests were inspected for action selection. I also wanted it to fall back on the default action selector if my own selection is unable to select a suitable action. The first step was to create the skeleton for the class:

public class CustomActionSelector : ApiControllerActionSelector
{
  public override HttpActionDescriptor SelectAction(HttpControllerContext context)
  {
    return base.SelectAction(context);
  }
}

..and to configure the project to call it; in the Register method in WebApiConfig:

config.Services.Replace(typeof(IHttpActionSelector), new CustomActionSelector());

The second was to add some constraints. It is only meaningful to execute the action selection logic if the request is a Post and the request body has some content of json mime type.

public override HttpActionDescriptor SelectAction(HttpControllerContext context)
{
  var request = new HttpMessageContent(context.Request).HttpRequestMessage;

  if (request.Method == HttpMethod.Post &&
      request.Content.Headers.ContentType.MediaType.Equals("application/json"))
  {
    var json = request.Content.ReadAsStringAsync().Result;

    if (!string.IsNullOrWhiteSpace(json))
    {
    }
  }

  return base.SelectAction(context);
}

For the logic I wanted to consider all public actions that are decorated with the [HttpPost] attribute and that has at least one parameter.

private static IEnumerable<MethodInfo> GetActionMethods(HttpControllerContext context)
{
  return context.ControllerDescriptor.ControllerType
           .GetMethods(BindingFlags.Instance | BindingFlags.Public)
           .Where(m => m.GetCustomAttributes(typeof(HttpPostAttribute)).Any())
           .Where(m => m.GetParameters().Any());
}

After some experimentation with different approaches to selecting the best action I had to accept the fact that this whole thing got so much easier and faster if I introduced a couple of restraints:

  1. The parameter that is to be mapped to the json data has to be the first parameter.
  2. Required properties on the models corresponding to the incoming json objects has to be decorated with the [Required] attribute.
Obviously, there are ways of coding around these restraints and making the algorithm much more flexible but for now this was a good enough solution to solve my problem.
private static IEnumerable<string> GetRequiredParameterNames(MethodInfo methodInfo)
{
  return methodInfo.GetParameters()[0].ParameterType
           .GetProperties()
           .Where(p => p.CanRead && p.GetCustomAttributes(typeof(RequiredAttribute)).Any())
           .Select(rm => rm.Name);
}
With that in place I could simply loop through the required properties on the first parameter type and ensure that the json data has corresponding keys with some value. A black-hole-try-catch ensures any exceptions from weird and unexpected input data are swallowed. No exception handling seems necessary since the only objective is to find out wether the posted data can be mapped to the action parameter or not.

Summary

This is what the result looks like. It sure is going to be interesting seeing some performance testing results on this :)

namespace Subject.Api.ActionSelectors
{
  using System.Collections.Generic;
  using System.ComponentModel.DataAnnotations;
  using System.Linq;
  using System.Net.Http;
  using System.Reflection;
  using System.Web.Http;
  using System.Web.Http.Controllers;

  using Newtonsoft.Json.Linq;

  public class CustomActionSelector : ApiControllerActionSelector
  {
    public override HttpActionDescriptor SelectAction(HttpControllerContext context)
    {
      var request = new HttpMessageContent(context.Request).HttpRequestMessage;

      if (request.Method == HttpMethod.Post &&
          request.Content.Headers.ContentType.MediaType.Equals("application/json"))
      {
        var json = request.Content.ReadAsStringAsync().Result;

        if (!string.IsNullOrWhiteSpace(json))
        {
          MethodInfo result = null;

          foreach (var actionMethod in GetActionMethods(context))
          {
            try
            {
              foreach (var methodName in GetRequiredParameterNames(actionMethod))
              {
                if (JObject.Parse(json)?.GetValue(methodName)?.Value<object>() == null)
                {
                  break;
                }

                result = actionMethod;
              }
            }
            catch
            {
              // Swallow these exceptions
            }
          }

          if (result != null)
          {
            return new ReflectedHttpActionDescriptor(context.ControllerDescriptor, result);
          }
        }
      }

      return base.SelectAction(context);
    }

    private static IEnumerable<MethodInfo> GetActionMethods(HttpControllerContext context)
    {
      return context.ControllerDescriptor.ControllerType
               .GetMethods(BindingFlags.Instance | BindingFlags.Public)
               .Where(m => m.GetCustomAttributes(typeof(HttpPostAttribute)).Any())
               .Where(m => m.GetParameters().Any());
    }

    private static IEnumerable<MethodInfo> GetActionMethods(HttpControllerContext context)
    {
      return context.ControllerDescriptor.ControllerType
               .GetMethods(BindingFlags.Instance | BindingFlags.Public)
               .Where(m => m.GetCustomAttributes(typeof(HttpPostAttribute)).Any())
               .Where(m => m.GetParameters().Any());
    }
  }
}

No comments:

Post a Comment