Saturday, December 26, 2015

Microsoft 70-486: Design and implement routes

Exam Objectives


Define a route to handle a URL pattern, apply route constraints, ignore URL patterns, add custom route parameters, define areas

Quick Overview of Training Materials



Define a route


Routes can be defined two ways in ASP.NET MVC:  through method calls (Traditional Routing) or as attributes on controller methods.  Traditional routing is generally accomplished by using calls to routing.Add or routing.MapRoute in the RouteConfig.cs file under App_Start.  This example comes from my post on extensibility in MVC and demonstrates both ways to accomplish the same end:

routes.Add(new Route(
    url: "AspCompat/{controller}/{action}",
    defaults: new RouteValueDictionary(
        new { controller = "Home", action = "ThreadState" }),
    routeHandler: new AspCompatHandler()));
 
routes.MapRoute(
    name: "AspCompatRoute",
    url: "AspCompat/{controller}/{action}",
    defaults: new { controller = "Home", action = "ThreadState" }
).RouteHandler = new AspCompatHandler();

MCV 5 introduced attribute routing, which lets us define routes directly on the controllers and their action methods.  The first part of making attribute routing work is to add routes.MapMvcAttributeRoutes() into RouteConfig.cs to map attribute routes.  Once this is done, routes can be defines as attributes on the target methods, and route prefixes for a given controller can be defined on that controller:

[RoutePrefix("Home")]
public class HomeController : Controller
{
    
    [Route("Attrib/{echo}")]
    [Route("Attrib")]
    [Route("Foo/{echo}")]
    [Route("Default")]
    public string RoutingExample(string echo = "default")
    {
        return "You said: " + echo;
    }

Multiple attributes can be defined on the same method.  In the above example, going to http://localhost/Home/Foohttp://localhost/Home/Attrib, and http://localhost/Home/Default all call the same method (though Foo requires a parameter, Attrib can be called with or without a parameter, and Default will not accept a parameter).  The attribute on the controller class is what adds the "Home" into the URL.  Without this attribute the routes would look like http://localhost/Foo etc.

Controller attributes can also be used to define all the routes for that controller.  If we wanted to replace the default home controller routes with attribute routes, we could add the attribute [Route("home/{action}"] to the controller, and we wouldn't need any attributes on the methods themselves.  This doesn't preclude us from adding additional routes to certain methods (like a default route to Index).  This route prefix can be escaped by beginning a route definition with ~/ like so:

[Route("~/Attrib/{echo}")]
[Route("~/Attrib")]
public string RoutingExample(string echo = "default")
{
    return "You said: " + echo;
}

The URL for this method would start from the base URL,  http://localhost/Attrib, without "Home".


Apply route constraints


By default, any non-empty value will be mapped to a route parameter.  This can create problems if the basic signature of two route is the same, but the expected values differ fundamentally.  The solution is to use route constraints.

Route constraints in attributes are defined as part of the URL string. A colon followed by the constraint is added to the route parameter:

[Route("~/Echo/{echo:int}")]
public string IntEcho(int echo)
{
    return "Number is: " + echo;
}
 
[Route("~/Echo/{echo=default}")]
public string StringEcho(string echo)
{
    return "You said: " + echo;
}



The constraint can take several forms:

  • specifying a primative type such as int above.  Other options are bool, datetime, decimal, double, float, guid, and long
  • contraint with a parameter such as length(), minlength(), maxlength(), min(), max(), and range()
  • regex(), which allows you to specify a regular expression the parameter must match, or alpha, which is equivalent to the regular expression [A-Za-z]+


In traditional routing, route constraints are defined in an additional dictionary passed to the MapRoute or Add methods in RouteConfig.   The following example is adapted from the O'Reilly text:

routes.MapRoute(
    name: "noram",
    url: "{controller}/Index/{id}",
    defaults: new { controller = "Home", action = "noram"},
    constraints: new { id = "(us|ca)" }
);
 
routes.MapRoute(
    name: "europe",
    url: "{controller}/Index/{id}",
    defaults: new { controller = "Home", action = "europe"},
    constraints: new { id = "(uk|de|es|it|fr|be|nl)" }
);

The methods in HomeController look like this:

public string noram(string id)
{
    return "Welcome to North America. Id = " + id;
}
 
public string europe(string id)
{
    return "Welcome to Europe. Id = " + id;
}

With the following results:


It is also possible to specify route constraints using the IRouteConstraint interface.  You create a class implementing this interface, which has one method: Match(), which returns a boolean value based on the httpContext and other parameters.  This allows you to make some fairly sophisticated decisions, such as whether the value exists in a database.  Here is the IRouteConstraint equivalent of the "europe" code above, adapted from the example in the O'Reilly text:

The route in RouteConfig.cs:
routes.MapRoute(
    name: "europe",
    url: "{controller}/Index/{id}",
    defaults: new { controller = "Home", action = "europe"},
    constraints: new EuropeConstraint()
);

The constraint class:
public class EuropeConstraint : IRouteConstraint
{
    public bool Match(HttpContextBase httpContext, Route route, string parameterName,
    RouteValueDictionary values, RouteDirection routeDirection)
    {
        List<string> vals = new List<string>(){"uk","de","es","it","fr","be","nl"};
        string id = (string)values["id"];
 
        return vals.Contains(id);
    }
}


Ignore URL patterns


Ignoring routes is fairly simple, as it is accomplished with a call to routes.IgnoreRoute(urlpattern) in RouteConfig.cs.  The base project includes an example, ignoring all paths to files ending in .axd:

public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

Another use case pointed out in the O'Reilly text is to prevent the ASP.NET MVC runtime from trying to process requests to another portion of the application that uses another language or framework (such as if part of the application was written in php).


Add custom route parameters


In both traditional routing and attribute routing, route parameters are defined using curly braces to surround the parameter name.  The code snippets above shows the {controller} and {action} parameters, which are used by the routing system to determine which class and method to use to execute the request.  In the attribute example, I use a parameter {echo} which holds a string that is returned back to the caller.

Route parameters can be given default values in a number of different ways.  With attribute routing, the default value can be set in the url string. The default value can also be set in the method signature.  Finally, the Add and MapRoute methods include overrides that take a RouteValueDictionary with default values defined:

Default value in attribute:
[Route("~/Echo/{echo=default}")]
public string StringEcho(string echo)
{
    return "You said: " + echo;
}

Default value in method signature:
[Route("~/Echo/{echo}")]
public string StringEcho(string echo="default")
{
    return "You said: " + echo;
}

Default values passed to Add():
routes.Add(new Route(
    url: "AspCompat/{controller}/{action}",
    defaults: new RouteValueDictionary(
        new { controller = "Home", action = "ThreadState" }),
    routeHandler: new AspCompatHandler()));


Define areas


Areas are fully covered in the WROX book.  Areas allow sets of Controllers, Models, and Views to be placed together within the project, which can make managing a large project easier.  Adding a new Area with Visual Studio is as simple as adding a class or controller, just right click on the project, Add -> Area, and give the Area a name.


The new areas are placed under a folder called "Areas" in the project structure.



Areas are registered in the Global.asax file before any other configuration:

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();
    //other config calls...
}

The fact that areas are registered before anything else has implications for using Attribute based routing and traditional routing together.  The authors of the WROX text recommend calling RouteTable.Routes.MapMvcAttributeRoutes() before the call to .RegisterAllAreas().

One other gotcha pointed out by the book occurs when you use the same Controller name in an area and in the project root.  Unless you specify a namespace in the route, doing this can create an ambiguity that throws an exception:


Once the namespace is added to the route, both the root route (which in this project just goes to the template home page) and the route to the area work as expected.

routes.MapRoute(
    name: "Default",
    url: "{controller}/{action}/{id}",
    defaults: new { controller = "Home", 
        action = "Index", id = UrlParameter.Optional },
    namespaces: new [] { "AreasDemo.Controllers" }
);

The offending controller looks like below.  Notice the namespace includes "Areas.{area name}"
namespace AreasDemo.Areas.Default.Controllers
{
    public class HomeController : Controller
    {
        // GET: Default/Home
        public string Index()
        {
            return "Area Demo, Default, Home, Index";
        }
    }
}



1 comment:

  1. Oddly, I noticed in MVC 5 in VS 2017 that the routes.MapMvcAttributeRoutes(); has to come before other route.MapRoute(...); for it to work correctly. Otherwise the routes defined in the controller are not found. BTW, Great Blog Series!

    ReplyDelete