ASP.NET Web API Catch-All Route Parameter Binding

ASP.NET Web API has a concept of Catch-All routes but the frameowk doesn't automatically bind catch-all route values to a string array. Let's customize it with a custom HttpParameterBinding.
2012-08-29 16:14
Tugberk Ugurlu


I just realized that ASP.NET Web API doesn’t bind catch-all route values as ASP.NET MVC does. If you are not familiar with catch all routing, Stephen Walter has a great explanation on his article under the "Using Catch-All Routes" section.

In ASP.NET MVC, when you have a route as below, you can retrieve the values of the catch all parameter as string array.

RouteTable.Routes.MapRoute(
    "CatchAllRoute",
    "blog/tags/{*tags}",
    new { controller = "blog", action = "tags" }
);

The controller action would look like as below:

public class BlogController : Controller {

    public ActionResult Tags(string[] tags) { 

        //...
    }
}

In ASP.NET Web API, we don’t have that capability. If we have a catch-all route, we could retrieve it as string and parse it manually but that would be so lame to do it inside the controller, isn’t it? There must be a better way. Well, there is! We can create a custom HttpParameterBinding and register it globally for string arrays. If you are interested in learning more about parameter binding in ASP.NET Web API, you might wanna have a look at Mike Stall’s WebAPI Parameter binding under the hood blog post. In our case, the custom HttpParameterBinding we want to create looks like as below:

public class CatchAllRouteParameterBinding : HttpParameterBinding {

    private readonly string _parameterName;
    private readonly char _delimiter;

    public CatchAllRouteParameterBinding(
        HttpParameterDescriptor descriptor, char delimiter) : base(descriptor) {

        _parameterName = descriptor.ParameterName;
        _delimiter = delimiter;
    }

    public override Task ExecuteBindingAsync(
        System.Web.Http.Metadata.ModelMetadataProvider metadataProvider,
        HttpActionContext actionContext,
        CancellationToken cancellationToken) {

        var routeValues = actionContext.ControllerContext.RouteData.Values;
            
        if (routeValues[_parameterName] != null) {

            string[] catchAllValues = 
                routeValues[_parameterName].ToString().Split(_delimiter);

            actionContext.ActionArguments.Add(_parameterName, catchAllValues);
        }
        else {

            actionContext.ActionArguments.Add(_parameterName, new string[0]);
        }

        return Task.FromResult(0);
    }
}

All the necessary information has been provided to us inside the ExecuteBindingAsync method. From there, we simply grab the values from the RouteData and see if there is any route value whose route parameter name is the same as the action method parameter name. If there is one, we go ahead and split the values using the delimiter char provided to us. If there is no, we just attach an empty string array for the parameter. At the end, we let our caller know that we are done by returning a pre-completed Task object. I was using .NET 4.5, so I simply used FromResult method of Task class. If you are on .NET 4.0, you can return a completed task by using TaskCompletionSource class.

The following code is the our catch-all route.

protected void Application_Start() {

    var config = GlobalConfiguration.Configuration;

    config.Routes.MapHttpRoute(
        "BlogpostTagsHttpApiRoute",
        "api/blogposts/tags/{*tags}",
        new { controller = "blogposttags" }
    );
}

The last thing is that we need to register a rule telling that if there is an action method parameter which is a type of string array, go ahead and use our custom HttpParameterBinding.

protected void Application_Start() {

    var config = GlobalConfiguration.Configuration;

    //...

    config.ParameterBindingRules.Add(typeof(string[]),
        descriptor => new CatchAllRouteParameterBinding(descriptor, '/'));
}

Now, if we send a request to /api/blogposts/tags/asp-net/asp-net-web-api, we would see that our action method parameter is bound.

image

So far so good but we might not want to register our HttpParameterBinding rule globally. Instead, we might want to specify it manually when we require it. Well, we can do that as well. We just need to create a ParameterBindingAttribute to get our custom HttpParameterBinding so that it will be used to bind the action method parameter.

public class BindCatchAllRouteAttribute : ParameterBindingAttribute {

    private readonly char _delimiter;

    public BindCatchAllRouteAttribute(char delimiter) {

        _delimiter = delimiter;
    }

    public override HttpParameterBinding GetBinding(HttpParameterDescriptor parameter) {

        return new CatchAllRouteParameterBinding(parameter, _delimiter);
    }
}

As you can see, it is dead simple. The only thing we need to do now is to apply this attribute to our action parameter:

public class BlogPostTagsController : ApiController {

    //GET /api/blogposts/tags/asp-net/asp-net-web-api
    public HttpResponseMessage Get([BindCatchAllRoute('/')]string[] tags) {

        //TODO: Do your thing here...

        return new HttpResponseMessage(HttpStatusCode.OK);
    }
}

When we send a request to /api/blogposts/tags/asp-net/asp-net-web-api, we shouldn’t see any difference.

image

I am still discovering how parameter and model binding works inside the ASP.NET Web API. So, there is a good chance that I did something wrong here :) If you spot it, please let me know :)



Comments

Mihai
by Mihai on Wednesday, Apr 16 2014 15:21:26 +00:00
Very nice article! But what about when we have a URL that finishes in .html? For example: /api/blogposts/tags/asp-net/asp-net-web-api.html I get a 404 Not Found, because the static file handler comes prior to the MVC engine of the Web Api. A workaround would be to use the RAMMFAR by doing: in but this is adding a lot of stuff, and it is not recommended to use it. Any ideea for this issue? Thank you!

New Comment