Wednesday, 13 September 2017

Attribute Routing in MVC and Web API

One of the limitations of the ASP.NET routing system is that it requires you to configure routes on a global route table. This has several consequences:
  • It forces you to encode action-specific information like parameter names in a global route table.
  • Routes registered globally can conflict with other routes and end up matching actions they aren't supposed to match.
  • The information about what URI to use to call into a controller is kept in a completely different file from the controller itself. A developer has to look at both the controller and the global route table in configuration to understand how to call into the controller.
An attribute-based approach solves all these problems by allowing you to configure how an action gets called right on the action itself. For most cases, this should improve usability and make MVC and Web API controllers simpler to build and maintain.

Scenarios

Note that while the examples given in this document are implemented as Web API controllers the same concepts apply equally to MVC controllers unless otherwise noted.

Scenario 1: Defining verb-based and action-based actions in the same controller

public class OrdersController : ApiController
{
    [Route("orders/{id}")]
    public Order Get(int id) { }
    [Route("orders/{id}/approve")]
    public void Approve(int id) { }
}

Scenario 2: Versioning controllers - different controllers for different versions of an API

[Route("api/v1/customers")]
public class CustomersV1Controller : ApiController { ... }
[Route("api/v2/customers")]
public class CustomersV2Controller : ApiController { ... }

Scenario 3: Nested controllers - hierarchical routing where one controller can be accessed as a sub-resource of another controller

public class MoviesController : ApiController
{
    [Route("actors/{actorId}/movies")]
    public Movie Get(int actorId) { }
    [Route("directors/{directorId}/movies")]
    public Movie Get(int directorId) { }
}

Scenario 4: Defining multiple ways to access a resource

public class PeopleController : ApiController
{
    [Route("people/{id:int}")]
    public Person Get(int id) { }
    [Route("people/{name}")]
    public Person Get(string name) { }
}
In the controller above, the request api/People/3 would match the first action because routes with constrained parameters are evaluated before unconstrained parameters.

Scenario 5: Defining actions with different parameter names

public class MyController : ApiController
{
    [Route("my/action1/{param1}/{param2")]
    public void Action1(string param1, string param2) { }
    [Route("my/action2/{x}/{y}")]
    public void Action2(string x, string y) { }
}

Scenario 6: Defining multiple ways to access a particular action

public class OrdersController : ApiController
{
    [Route("orders/{orderId}")]
    [Route("customer/{customerId}/orders/{orderId}")]
    public void Get(string customerId = null, string orderId) { }
}

Design

The experience for getting started with attribute-based routing witn MVC and Web API looks like this:
  1. Annotate your actions or controllers with the new RouteAttribute. Specify the route template in the constructor.
  2. For Web API call the HttpConfiguration.MapHttpAttributeRoutes() extension method when configuring routes. For MVC call the RouteTable.Routes.MapMvcAttributeRoutes() extension method.
This call will use the corresponding controller selector and the action selector to explore all the controllers and actions available and retrieve route-defining attributes. It will use these attributes to create routes and add these to a single composite route added to the server's route collection.

This design allows attribute-based routing to compose well with the existing routing system since you can call MapHttpAttributeRoutes or MapMvcAttributeRoutes and still define regular routes using MapHttpRoute or MapRoute respectively. Here's an example:
config.MapHttpAttributeRoutes();
config.Routes.MapHttpRoute("Default", "api/{controller}");
In most cases, MapHttpAttributeRoutes or MapMvcAttributeRoutes will be called first so that attribute routes are registered before the global routes (and therefore get a chance to supersede global routes). Requests to attribute routed controllers would also be filtered to only those that originated from an attribute route. 

Optional parameters and default values

You can specify that a parameter is optional by adding a question mark to the parameter, that is:
[Route("countries/{name?}")]
public Country GetCountry(string name = "USA") { }
Currently, a default value must be specified on the optional parameter for action selection to succeed, but we can investigate lifting that restriction. (Please let us know if this is important.)

Default values can be specified in a similar way:
[Route("countries/{name=USA}")]
public Country GetCountry(string name) { }
The optional parameter '?' and the default values must appear after inline constraints in the parameter definition.

Inline Constraints

Route constraints can be applied to particular parameters in the route template itself. Here's an example:
[Route("people/{id:int}")]
public Person Get(int id) { }
This action will only match if id in the route can be converted to an integer. This allows other routes to get selected in more general cases.

The following default inline constraints are defined:

Constraint KeyDescriptionExample
boolMatches a Boolean value{x:bool}
datetimeMatches a DateTime value{x:datetime}
decimalMatches a Decimal value{x:decimal}
doubleMatches a 64-bit floating point value{x:double}
floatMatches a 32-bit floating point value{x:float}
guidMatches a GUID value{x:guid}
intMatches a 32-bit integer value{x:int}
longMatches a 64-bit integer value{x:long}
minlengthMatches a string with the specified minimum length{x:minlength(4)}
maxlengthMatches a string with the specified maximum length{x:maxlength(8)}
lengthMatches a string with the specified length or within the specified range of lengths{x:length(6)}, {x:length(4,8)}
minMatches an integer with the specified minimum value{x:min(100)}
maxMatches an integer with the specified maximum value{x:max(200)}
rangeMatches an integer within the specified range of values{x:range(100,200)}
alphaMatches English uppercase or lowercase alphabet characters{x:alpha}
regexMatches the specified regular expression{x:regex(^\d{3}-\d{3}-\d{4}$)}

Inline constraints can have arguments specified in parentheses - this is used by several of the built-in constraints. Inline constraints can also be chained with a colon used as a separator like this:
[Route("people/{id:int:min(0)}")]
public Person Get(int id) { }
Inline constraints must appear before the optional parameter '?' and default values, and the constraint resolution is extensible. See below for details.

Per-controller routes

Specifying a route for each action can become repetitive. Instead, you can simply define a route for an entire controller. For example, this controller routes each action:

public class CustomersController : ApiController
{
    [Route("customers")]
    public IEnumerable<Customer> Get() { }
    [Route("customers/{id}")]
    public Customer Get(int id) { }
    [Route("customers")]
    public void Post(Customer customer) { }
}

It could instead be simplified like this:

[Route("customers/{id?}")]
public class CustomersController : ApiController
{
    public IEnumerable<Customer> Get() { }
    public Customer Get(int id) { }
    public void Post(Customer customer) { }
}

Note that per-controllerroutes can contain route variables, optional values, default values and constraints.

You can override the per-controller route for an action by explicitly specifying the route for that action:

[Route("customers/{id?}")]
public class CustomersController : ApiController
{
    public IEnumerable<Customer> Get() { }
    public Customer Get(int id) { }
    public void Post(Customer customer) { }
    [Route("customers/{id}/orders")]
    public IEnumerable<Order> GetOrders(int id)
}

Route prefixes

You may want to specify a common route prefix for an entire controller. A route prefix does not add any routes - instead it defines a common prefix to be applied to any specified routes including per-controller routes. For example:

[RoutePrefix("customers")]
[Route("{id?}")]
public class CustomersController : ApiController
{
    // GET customers
    public IEnumerable<Customer> Get() { }
    // GETcustomers/123
    public Customer Get(int id) { }
    // POST customers
    public void Post(Customer customer) { }
    // GET customers/123/orders
    [Route("{id}/orders")]
    public IEnumerable<Order> GetOrders(int id)
}

Route prefixes may contain route variables and inline constraints. Optional parameters and default values cannot be used in route prefixes as they must appear at the end of a route.

Areas

In MVC routes can be mapped to an area. If a RouteAreaAttribute is applied on an MVC controller, all of the routes on that controller would be associated with the area, and their template would be prefixed accordingly
For example:
[RouteArea("PugetSound")]
public class FooController : Controller
{
    [Route("bar")]
    public ActionResult Bar() { ... }
}
The resulting route url would be PugetSound/bar.

The actual prefix applied for a given area can be tweaked by setting the AreaPrefix parameter:
[RouteArea("PugetSound", AreaPrefix = "puget-sound")]
public class FooController : Controller
{
    [Route("bar")]
    public ActionResult Bar() { ... }
}
The resulting route url in that case would be puget-sound/bar.

Disabling route prefix for a specific route

In order to have a specific route bypass the prefix that is set on its controller, the following syntax would be used:
[RoutePrefix("foo")]
public class FooController : Controller
{
    [Route("bar")]
    public ActionResult Bar() { ... }

    [Route("~/baz")]
    public ActionResult Baz() { ... }
}
This will generate the following two routes:
  1. foo/bar
  2. baz

Naming

Web API's routing system requires every route to have a distinct name. Route names are useful for generating links by allowing you to identify the route you want to use. You can choose to define the route name right on the attribute itself:
[Route("customers/{id}", Name = "GetCustomerById")]

In Web API if a route name is not specified then the route cannot be used for link generation.

Ordering

There is an Order property on RouteAttribute that allows you to specify that one route should take precedence over another when doing action selection. The default order is 0, and routes with a smaller order get higher precedence. Negative numbers can be used to specify higher precedence than the default precedence. In addition, a default precedence is used to order routes that don't have an order specified.

Here's how the ordering works:
  1. Go through the parsed route segment by segment. Apply the following ordering for determining which route goes first:
    1. Literal segments
    2. Constrained parameter segments
    3. Unconstrained parameter segments
    4. Constrained wildcard parameter segments
    5. Unconstrained wildcard parameter segments
  2. If no ordering can be determined up to this point then the action selection is ambiguous and results in an error response.

Extensibility

IInlineConstraintResolver

The IInlineConstraintResolver interface is responsible for taking an inline constraint and manufacturing an IHttpRouteConstraint for that parameter:
public interface IInlineConstraintResolver
{
    IHttpRouteConstraint ResolveConstraint(string inlineConstraint);
}


The default constraint resolver uses a dictionary based approach of mapping constraint keys to a particular constraint type. It contains logic to create an instance of the type based on the constraint arguments. It exposes the dictionary publicly so that you can add custom constraints without having to implement IInlineConstraintResolver. Here's an example:
DefaultInlineConstraintResolver constraintResolver = new DefaultInlineConstraintResolver();
constraintResolver.ConstraintMap.Add("phonenumber", typeof(PhoneNumberRouteConstraint));

RouteProviderAttribute and IDirectRouteProvider

The RouteProviderAttribute abstract class and IDirectRouteProvider interface allow further customization of the routing details, such as adding custom constraints on a per-route basis. While IInlineConstraintResolver allows adding support globally for specific custom constraints based on the presence of something in the route template, RouteProviderAttribute allows adding arbitrary custom constraints, defaults and data tokens on a per-attribute basis, and IDirectRouteProvider further allows returning custom IHttpRoute implementations.

public abstract class RouteProviderAttribute : Attribute
{
    protected RouteProviderAttribute(string template) { }

    public string Template { get; }
    public string Name { get; set; }
    public int Order { get; set; }
    public virtual HttpRouteValueDictionary Constraints { get; }
}

    public interface IDirectRouteProvider
    {
        RouteEntry CreateRoute(DirectRouteProviderContext context);
    }

For example, a custom route attribute might support versioning based on a custom header using a route constraint:

public class HomeController : ApiController
{
    [Route]
    public IHttpActionResult Get()
    {
        return Json("Hello from V1");
    }

    [VersionedRoute(Version = 2)]
    public IHttpActionResult GetV2()
    {
        return Json("Hello from V2");
    }
}

class VersionedRouteAttribute : RouteProviderAttribute
{
    public VersionedRouteAttribute(): base(null)
    {
        Order = -1;
    }

    public int Version { get; set; }

    public override HttpRouteValueDictionary Constraints
    {
        get
        {
            return new HttpRouteValueDictionary
            {
                { "", new Constraint(Version) }
            };
        }
    }

    private class Constraint : IHttpRouteConstraint
    {
        private readonly int _version;

        public Constraint(int version)
        {
            _version = version;
        }

        public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string, object> values, HttpRouteDirection routeDirection)
        {
            if (!request.Headers.Contains("Version"))
            {
                return false;
            }
            var versions = request.Headers.GetValues("Version");
            var versionHeader = versions.First();
            int version;

            if (int.TryParse(versionHeader, out version))
            {
                return version == _version;
            }
            else
            {
                return false;
            }
        }
    }
}

Using IDirectRouteProvider, the code would be slightly different:
class VersionedRouteAttribute : Attribute, IDirectRouteProvider
{
    public int Version { get; set; }

        public RouteEntry CreateRoute(DirectRouteProviderContext context)
        {
            DirectRouteBuilder builder = context.CreateBuilder(/*"<Template, if desired>"*/);
            builder.Order = -1;

            if (builder.Constraints == null)
            {
                builder.Constraints = new HttpRouteValueDictionary();
            }

            builder.Constraints.Add("", new Constraint(Version));
            return builder.Build();
        }

No comments:

Post a Comment