HOWTO: Using NonBindable Service Level ODATA Actions

Following the very cool series of ODATA articles on the ASP.NET site by Mike Wasson you will see that it is possible to define actions not only on entities directly, but also (unbound) on entity sets and the service root itself. While it is fairly straightforward to implement entity set action (the same as an entity action but without the key parameter) the same is not so obvious for unbound actions at the service itself. So following the standard procedure of defining a service route, service root action and its implementation, it could look similar to this:

// WebApiConfig.cs
public static class WebApiConfig
{
  public static void Register(HttpConfiguration config)
  {
    config.Routes.MapODataServiceRoute(
      routeName: "Utilities.svc"
      ,
      routePrefix: "Utilities.svc"
      ,
      model: GetModel("Utilities")
      ,
      batchHandler: new DefaultODataBatchHandler(GlobalConfiguration.DefaultServer)
      );
    config.MapHttpAttributeRoutes();
  }
  private static IEdmModel GetModel(string ContainerName)
  {
    ODataConventionModelBuilder builder = new ODataConventionModelBuilder();
    builder.ContainerName = ContainerName;

    var builderAction = builder.Action("ServiceRootAction");
    builderAction.Parameter<string>("inputParam1");
    builderAction.Returns<string>();

    return builder.GetEdmModel();
  }
}

// NonbindableActionsController.cs with service root action
[HttpPost]
public async Task<IHttpActionResult> ServiceRootAction(ODataActionParameters parameters)
{
  string fn = string.Format("{0}:{1}.{2}", this.GetType().Namespace, this.ControllerContext.ControllerDescriptor.ControllerName, this.ControllerContext.Request.GetActionDescriptor().ActionName);
  Debug.WriteLine(fn);
  if (!ModelState.IsValid)
  {
    return BadRequest(ModelState);
  }

  try
  {
    if(null == parameters)
    {
      return BadRequest("Body cannot be null.");
    }
    var inputParam1 = parameters.Where(p => p.Key.Equals("param1")).Select(p => p.Value).FirstOrDefault();
    if (null == inputParam1)
    {
      return BadRequest("inputParam1: Parameter cannot be null.");
    }
    return Ok<string>(string.Format("{0}: Seng!", inputParam1));
  }
  catch (Exception ex)
  {
    Debug.WriteLine(String.Format("{0}: {1}\r\n{2}", ex.Source, ex.Message, ex.StackTrace));
    throw;
  }
}

However when invoking the action you end up either with a plain HTTP 404 telling that no controller could be found.

=== REQUEST ===
POST http://www.example.com/Utilities.svc/ServiceRootAction HTTP/1.1
Content-Length: 25
Content-Type: application/json

{"inputParam1":"tralala"}
=== RESPONSE ===
HTTP/1.1 404 Not Found

{
  "Message": "No HTTP resource was found that matches the request URI 'http://www.example.com/Utilities.svc/ServiceRootAction'.",
  "MessageDetail": "No route providing a controller name was found to match request URI 'http://www.example.com/Utilities.svc/ServiceRootAction'"
}

This seems strange as the service root action is not bound to any entity set, and so it is not clear which controller should be used for that. The solution to this: it actually does not matter in which controller the method/action Is defined. As WebAPI does not support NonBindable actions out of the box anyway, we have to manually add support for this in the call to “MapODataServiceRoute()”.
So when having a look at the ASP.NET code samples on their Codeplex Git one can see that we have to implement a class for the “IODataRoutingConvention” interface that acts a helper to select the right controller. Quoting the example code from Mike Wasson Supporting OData Actions in ASP.NET Web API 2 this will end up looking like this:

// WebApiConfig.cs
public static class WebApiConfig
{
  public static void Register(HttpConfiguration config)
  {
    var conventions = ODataRoutingConventions.CreateDefault();
    conventions.Insert(0, new NonBindableActionRoutingConvention("NonBindableActions")); 
    config.Routes.MapODataServiceRoute(
      routeName: "Utilities.svc"
      ,
      routePrefix: "Utilities.svc"
      ,
      model: GetModel("Utilities")
      ,
      pathHandler: new DefaultODataPathHandler()
      ,
      routingConventions: conventions
      ,
      batchHandler: new DefaultODataBatchHandler(GlobalConfiguration.DefaultServer)
      );
    config.MapHttpAttributeRoutes();
  }
  private static IEdmModel GetModel(string ContainerName)
  {
    ODataConventionModelBuilder builder = new ODataConventionModelBuilder();
    builder.ContainerName = ContainerName;

    var builderAction = builder.Action("ServiceRootAction");
    builderAction.Parameter<string>("inputParam1");
    builderAction.Returns<string>();

    return builder.GetEdmModel();
  }
}

// NonBindableActionRoutingConvention.cs
using System.Linq;
using System.Net.Http;
using System.Web.Http.Controllers;
using System.Web.Http.OData.Routing;
using System.Web.Http.OData.Routing.Conventions;
using Microsoft.Data.Edm;

namespace LightSwitchApplication
{
  // Implements a routing convention for non-bindable actions.
  // The convention maps "MyAction" to Controller:MyAction() method, where the name of the controller 
  // is specified in the constructor.
  public class NonBindableActionRoutingConvention : IODataRoutingConvention
  {
    private string _controllerName;

    public NonBindableActionRoutingConvention(string controllerName)
    {
      _controllerName = controllerName;
    }

    // Route all non-bindable actions to a single controller.
    public string SelectController(ODataPath odataPath, System.Net.Http.HttpRequestMessage request)
    {
      if (odataPath.PathTemplate == "~/action")
      {
        return _controllerName;
      }
      return null;
    }

    // Route the action to a method with the same name as the action.
    public string SelectAction(ODataPath odataPath, HttpControllerContext controllerContext, ILookup<string, HttpActionDescriptor> actionMap)
    {
      // OData actions must be invoked with HTTP POST.
      if (controllerContext.Request.Method == HttpMethod.Post)
      {
        if (odataPath.PathTemplate == "~/action")
        {
          ActionPathSegment actionSegment = odataPath.Segments.First() as ActionPathSegment;
          IEdmFunctionImport action = actionSegment.Action;

          if (!action.IsBindable && actionMap.Contains(action.Name))
          {
            return action.Name;
          }
        }
      }
      return null;
    }
  }
}
// taken from ASP.NET Codeplex ODATA v3 code samples, http://aspnet.codeplex.com/sourcecontrol/latest#Samples/WebApi/OData/v3/ODataActionsSample/ODataActionsSample/

You add a routing convention on how to route NonBindableActions and place these actions into a “fake” controller “NonBindableActionsController” that only holds these actions. You could certainly also place them into any other controller, but as they do not really belong to any entity set it is preferable to have them separate.

The information on this topic is relatively sparse and you really have to look at the code of the samples to find this out. This is a little bit unfortunate and time consuming. A short answer to this can actually be found at StackOverflow: http://stackoverflow.com/questions/15798915/asp-net-web-api-odata-action-on-the-edm-model-root (when looking for the right keyword).

Comments

  1. Martin Alley says:

    Very interesting. I’m having the same problem with OData 4. However the odata 4 samples include an example of an unbound action without apparently using a custom convention.

    • Ronald Rink says:

      Hi Martin, thanks for your information. To what sample are you referring? As for v4 I cannot find an example on CodePlex for unbound actions. If you sent me the link I would like to take a look into that. Thanks, Ronald

  2. Martin Alley says:

    FYI, I have found that adding custom conventions to the odata4 custom actions sample breaks the custom actions in the sample. In fact having
    config.MapODataServiceRoute(“OData”, “odata”, GetEdmModel()
    ,pathHandler: new DefaultODataPathHandler()
    , routingConventions: ODataRoutingConventions.CreateDefault()
    );
    breaks the unbound actions in the sample.

    So the question is, how to add custom conventions for CompositeKeyRouting, and CreateNavigationPropertyRouting, without breaking the custom actions?

    Martin

  3. Martin Alley says:

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: