Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Best Practices for multiple POST calls in Web Api on the same controller

I have a question for you guys that do Web API REST services. How do you design your services to handle a POST of a single entity, as well as being able to receive a POST of a collection of said entity?

For example:

public IHttpActionResult Post([FromBody]User value)
{
    // stuff
}

public IHttpActionResult Post([FromBody]IEnumerable<User> values)
{
    // stuff
}

Out of the box, this does not work because the default route matches both of these.

I know there are several different ways that I can tackle this, but I'm wanting to learn the "best practice" way.

What do you do to accomplish this same behavior?

My thoughts are as follows:

  1. I could make it so the signature of the post just takes a List as a parameter. I'd be doing away with the one that just takes a single User. Any code using that api call would just have to know to wrap its entity in a collection of some kind.
  2. I can create two different controllers, api/user and api/users each having their own POST. This approach doesn't really jive with REST, since api/user retrieves all users, and api/user/1 retrieves user with Id == 1, so what would api/users mean? What would api/users/1 mean? etc... so probably not this option.
  3. Try to get this to work with some set of custom constraints combined with ActionName attributes in the controller, with routes written up for each POST (I am not certain if this one will work at all).
  4. Make it an RPC call. If this is the case, what do you name your RPC controllers? Where do you locate them in the solution when some are REST and some are RPC? What criteria should I use to determine if an RPC call is required or if I should leave it as REST?
  5. Something else entirely?

Thank you for your words of wisdom. I really appreciate any/all participation in this. I'm really just trying to get a grasp on what the best practice is. Any examples that can be given would also be super!

like image 228
David Gunderson Avatar asked Jul 12 '14 03:07

David Gunderson


2 Answers

I ended up using a combination of my original third and fourth thoughts.

I'm adding my own answer to this to demonstrate how I got this to work. Out of all the Googling I've done, I hadn't found a crystal clear example on how to do this. I decided against making a single call that always takes an IEnumerable regardless of wanting to post one or multiple. The reason for this decision is the longer I thought about it, the more I realized the behavior involved for one or multiple users being inserted is completely different. For example, if I submit one user and it fails validation because of not filling out a required field, I expect to receive an error response containing details of why the server refused it. In the event of submitting multiple users at once, would this still be the case? Would I need error reasons given for each user that failed during the post? With my needs, the answer is no. That needs to be handled differently.

Thus, the answer for me was to combine REST calls with RPC (Remote Procedure Calls) in my web api solution. However, my requirements if going down that road are that the RPC calls need to be in different controllers, but the web address needs to still point to the same overall "Controller" (the {controller} part of a route like api/{controller} ).

For example, this web api url accepts the REST verbs Get, Post, Put, and Delete:

api/User

My call to submit multiple users needs to accept a POST at:

api/User/import

...but the logic for each of these calls needs to be in different controllers.

I was able to achieve this by doing the following:

  • Write a custom implementation of IHttpControllerSelector
  • Have 2 route maps configured, one for REST and one for RPC
  • Write 2 custom Route Constraints for determining REST or RPC route parameter
  • Modify the DefaultApi route map to use the REST Constraint
  • Add an "RpcApi" route that uses the RPC Constraint

Now I'll break down actual code on how I achieved this.

My ControllerSelector is as follows:

public class MyHttpControllerSelector : IHttpControllerSelector
{
    private const string ActionKey = "action";
    private const string ControllerKey = "controller";

    private readonly HttpConfiguration _configuration;
    private readonly Lazy<Dictionary<string, HttpControllerDescriptor>> _controllers;

    public MyHttpControllerSelector(HttpConfiguration config)
    {
        _configuration = config;
        _controllers = new Lazy<Dictionary<string, HttpControllerDescriptor>>(InitializeControllerDictionary);
    }

    private Dictionary<string, HttpControllerDescriptor> InitializeControllerDictionary()
    {
        var dictionary = new Dictionary<string, HttpControllerDescriptor>(StringComparer.OrdinalIgnoreCase);

        var controllerTypes = GetControllerTypes();

        foreach (var type in controllerTypes)
        {
            var controllerName = type.Name.Remove(type.Name.Length - DefaultHttpControllerSelector.ControllerSuffix.Length);

            dictionary[controllerName] = new HttpControllerDescriptor(_configuration, type.Name, type);  
        }

        return dictionary;
    }

    private IEnumerable<Type> GetControllerTypes()
    {
        var assembliesResolver = _configuration.Services.GetAssembliesResolver();
        var controllersResolver = _configuration.Services.GetHttpControllerTypeResolver();

        return controllersResolver.GetControllerTypes(assembliesResolver);
    }

    private static T GetRouteVariable<T>(IHttpRouteData routeData, string name)
    {
        object result = null;

        if (routeData.Values.TryGetValue(name, out result))
        {
            return (T)result;
        }

        return default(T);
    }

    public HttpControllerDescriptor SelectController(HttpRequestMessage request)
    {
        var routeData = GetRouteData(request);

        var controllerName = GetRequestedControllerName(routeData);

        var actionName = GetRequestedActionName(routeData);

        var isApiRoute = GetIsApiRoute(routeData);

        var controllerSelectorKey = GetControllerSelectorKey(actionName, controllerName, isApiRoute);

        return GetControllerDescriptor(request, controllerSelectorKey);
    }

    private bool GetIsApiRoute(IHttpRouteData routeData)
    {
        return routeData.Route.RouteTemplate.Contains("api/");
    }

    private static IHttpRouteData GetRouteData(HttpRequestMessage request)
    {
        var routeData = request.GetRouteData();

        if (routeData == null)
            throw new HttpResponseException(HttpStatusCode.NotFound);

        return routeData;
    }

    private HttpControllerDescriptor GetControllerDescriptor(HttpRequestMessage request, string controllerSelectorKey)
    {
        HttpControllerDescriptor controllerDescriptor = null;

        if (!_controllers.Value.TryGetValue(controllerSelectorKey, out controllerDescriptor))
        {
            throw new HttpResponseException(HttpStatusCode.NotFound);
        }

        return controllerDescriptor;
    }

    private static string GetControllerSelectorKey(string actionName, string controllerName, bool isApi)
    {
        return string.IsNullOrWhiteSpace(actionName) || !isApi
            ? controllerName
            : string.Format("{0}{1}", controllerName, "Rpc");
    }

    private static string GetRequestedControllerName(IHttpRouteData routeData)
    {
        string controllerName = GetRouteVariable<string>(routeData, ControllerKey);

        if (controllerName == null)
        {
            throw new HttpResponseException(HttpStatusCode.NotFound);
        }

        return controllerName;
    }

    private static string GetRequestedActionName(IHttpRouteData routeData)
    {
        return GetRouteVariable<string>(routeData, ActionKey);
    }

    public IDictionary<string, HttpControllerDescriptor> GetControllerMapping()
    {
        return _controllers.Value;
    }
}

Here is my IsRestConstraint:

public class IsRestConstraint : IHttpRouteConstraint
{
    public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string, object> values,
                      HttpRouteDirection routeDirection)
    {
        if (values.ContainsKey(parameterName))
        {
            string id = values[parameterName] as string;

            return string.IsNullOrEmpty(id) || IsRest(id);
        }
        else
        {
            return false;
        }
    }

    private bool IsRest(string actionName)
    {
        bool isRest = false;

        Guid guidId;
        int intId;

        if (Guid.TryParse(actionName, out guidId))
        {
            isRest = true;
        }
        else if (int.TryParse(actionName, out intId))
        {
            isRest = true;
        }

        return isRest;
    }
}

Here is my IsRpcConstraint:

public class IsRpcConstraint : IHttpRouteConstraint
{
    public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string, object> values,
                      HttpRouteDirection routeDirection)
    {
        if (values.ContainsKey(parameterName))
        {
            string action = values[parameterName] as string;

            return !string.IsNullOrEmpty(action) && IsRpcAction(action);
        }
        else
        {
            return false;    
        }
    }

    private bool IsRpcAction(string actionName)
    {
        bool isRpc = true;

        Guid guidId;
        int intId;

        if (Guid.TryParse(actionName, out guidId))
        {
            isRpc = false;
        }
        else if (int.TryParse(actionName, out intId))
        {
            isRpc = false;
        }

        return isRpc;
    }
}

In my WebApiConfig, my routes look like the following (notice where I also replace the default IHttpControllerSelector with my MyHttpControllerSelector, as well as where I use the custom constraints IsRpcConstraint and IsRestConstraint):

config.MapHttpAttributeRoutes();

config.Services.Replace(typeof(IHttpControllerSelector), new MyHttpControllerSelector(config));

config.Routes.MapHttpRoute(
    name: "RpcApi",
    routeTemplate: "api/{controller}/{action}/{id}",
    defaults: new { id = RouteParameter.Optional },
    constraints: new { action = new IsRpcConstraint() }
);

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

So when a request comes in, it is considered an RPC call if the third segment in the URI (the {action} in the "RpcApi" route) is NOT an integer, NOT a guid, and NOT empty. Likewise, it is considered a REST call if that third segment on the "DefaultApi" route template is either an integer OR a Guid OR is not supplied at all.

With this, the requests get mapped to their correct routes, and the MyHttpControllerSelector will select the appropriate controller accordingly. So, if a call is being made to:

api/User/1

Then the MyHttpControllerSelector will use the controller named UserController. Likewise, if a call is being made to:

api/User/import

Then the MyHttpControllerSelector will use the controller named UserRpcController and the call will map to the Import action in there automatically.

So far, this has made it so all I have to do for RPC support is add a controller with "Rpc" in it that has a prefix of my domain entity (in my case, a User). It could be TreeController and TreeRpcController, DogController and DogRpcController, and the endpoints would be:

api/Tree       (TreeController)
api/Tree/1     (TreeController)
api/Tree/grow  (TreeRpcController)
api/Dog        (DogController)
api/Dog/1      (DogController)
api/Dog/bark   (DogRpcController)

On top of that, I get a clean WebApiConfig. It is not getting polluted with lots of specific route templates and controller selections within each route. I only need 2 route maps specified no matter how many REST controllers and RPC Controllers get added to the solution.

This approach does make the assumption that a parameter on a REST call for the {id} segment has to be an int or a guid to be considered for a REST controller. With this setup, a plain ol' string would be considered an "action" and thus mapped to my Rpc controller. For my scenario, this is fine. I'm only using ints and Guids for ids.

I should also add that so far there is no requirement to have any kind of UI in this web api service. At some point down the road, that will come, so I have the controller selector MyHttpControllerSelector setup to automatically return a regular controller (non RPC) if it does NOT detect "api/" in the route template being used. This is for supporting a route template such as:

{controller}/{action}/{id}

Which is a regular MVC style controller route.

I modeled the MyHttpControllerSelector off of one that is found here:

http://blogs.msdn.com/b/webdev/archive/2013/03/08/using-namespaces-to-version-web-apis.aspx

The actual code is linked at the bottom of the article, pointing to here:

http://aspnet.codeplex.com/SourceControl/changeset/view/dd207952fa86#Samples/WebApi/NamespaceControllerSelector/NamespaceHttpControllerSelector.cs

It is an example on how to use a custom controller selector to use namespaces for versioning with your web api service. This technique is a bit outdated, because newer versions of web api as I understand it have better built in support for versioning. But I used this class as my starting point because it caches the results of values retrieved using reflection to select the correct controller, which is important for subsequent calls in the request for improved performance. I modified it a lot for my purposes.

Well, that's all I have to say about that.

like image 149
David Gunderson Avatar answered Sep 28 '22 06:09

David Gunderson


I generally like the approach of having just a single method per http verb in the controller. Mainly because this provides thin controllers with single responsibilities. I like to also name the method the same as the verb (Get, Post, Update, Delete etc)

It also has the added benefit of making url management very easy. In a lot of cases web api is hit from javascript and you have to store urls in config files or javascript files. If you use a single method per verb in the controller you can use the same url for all verbs and just rely on web api to serve the correct method based on https verbs in the header.

I see how attribute decorated routes can be useful too, but I worry that it is in some sense an invitation to creating very thick controllers with lots of methods.

like image 43
TGH Avatar answered Sep 28 '22 05:09

TGH