Chapter 8. URLs and Routing

Before ASP.NET MVC, the core assumption of routing in ASP.NET (just like in many other web application platforms) was that URLs correspond directly to files on the server's hard disk. The server executes and serves the page or file corresponding to the incoming URL. Table 8-1 gives an example.

Table 8-1. How URLs Have Traditionally Corresponded to Files on Disk

Incoming URL

Might Correspond To

http://mysite.com/default.aspx

e:webrootdefault.aspx

http://mysite.com/admin/login.aspx

e:webrootadminlogin.aspx

http://mysite.com/articles/AnnualReview

File not found! Send error 404.

This strictly enforced correspondence is easy to understand, but it's also very limiting. Why should my project's file names and directory structure be exposed to the public? Isn't that just an internal implementation detail? And what if I don't want those ugly .aspx extensions? Surely they don't benefit the end user. Historically, ASP.NET has encouraged the developer to treat URLs as a black box, paying no attention to URL design or search engine optimization (SEO). Common workarounds, such as custom 404 handlers and URL-rewriting ISAPI filters, can be hard to set up and come with their own problems.

Putting the Programmer Back in Control

ASP.NET MVC breaks away from this assumption. URLs are not expected to correspond to files on your web server. In fact, that wouldn't even make sense—since ASP.NET MVC's requests are handled by controller classes (compiled into a .NET assembly), there are no particular files corresponding to incoming URLs.

You are given complete control of your URL schema—that is, the set of URLs that are accepted and their mappings to controllers and actions. This schema isn't restricted to any predefined pattern and doesn't need to contain any file name extensions or the names of any of your classes or code files. Table 8-2 gives an example.

Table 8-2. How the Routing System Can Map Arbitrary URLs to Controllers and Actions

Incoming URL

Might Correspond To

http://mysite.com/photos

{ controller = "Gallery", action = "Display" }

http://mysite.com/admin/login

{ controller = "Auth", action = "Login" }

http://mysite.com/articles/AnnualReview

{ controller = "Articles", action = "View", contentItemName = "AnnualReview" }

This is all managed by the framework's routing system. Once you've supplied your desired routing configuration, the routing system does two main things:

  1. Maps each incoming URL to the appropriate request handler class

  2. Constructs outgoing URLs (i.e., to other parts of your application)

As you learned in Chapter 7, routing kicks in very early in the request processing pipeline, as a result of having UrlRoutingModule registered as one of your application's HTTP modules. In this chapter, you'll learn much more about how to configure, use, and test the core routing system.

About Routing and Its .NET Assemblies

The routing system was originally designed for ASP.NET MVC, but it was always intended to be shared with other ASP.NET technologies, including Web Forms. That's why the routing code doesn't live in System.Web.Mvc.dll, but instead is in a separate assembly (System.Web.Routing.dll in .NET 3.5, and simply System.Web.dll in .NET 4). Routing isn't aware of the concepts of "controller" and "action"—these parameter names are just arbitrary strings as far as routing is concerned, and are treated the same as any other parameter names you may choose to use. This chapter focuses on how to use routing with ASP.NET MVC, but much of the information also applies when using routing with other technologies.

Note

ASP.NET MVC 2 supports .NET 3.5 SP1, so it always references the routing code in System.Web.Routing.dll for .NET 3.5. But if you're running on .NET 4, then during compilation and at runtime, a .NET framework feature called type forwarding causes the routing classes to be loaded from .NET 4's System.Web.dll instead. Your project still has to reference System.Web.Routing.dll, though, because ASP.NET MVC 2 is compiled against it. It's exactly the same story with System.Web.Abstractions.dll. The stand-alone routing and abstractions assemblies are likely to be totally redundant (and will probably disappear) in ASP.NET MVC 3, which is expected to require .NET 4.

Setting Up Routes

To see how routes are configured, create a new ASP.NET MVC project and take a look at the Global.asax.cs file:

public class MvcApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        AreaRegistration.RegisterAllAreas(); // Will explain this later
        RegisterRoutes(RouteTable.Routes);
    }

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

        routes.MapRoute(
            "Default",                                   // Route name
            "{controller}/{action}/{id}",                // URL with parameters
            new { controller = "Home", action = "Index", // Parameter defaults
                  id = UrlParameter.Optional }
        );
    }
}

When the application first starts up (i.e., when Application_Start() runs), the RegisterRoutes() method populates a global static RouteCollection object called RouteTable.Routes. That's where the application's routing configuration lives. The most important code is that shown in bold: MapRoute() adds an entry to the routing configuration. To understand what it does a little more clearly, you should know that this call to MapRoute() is just a concise alternative to writing the following:

Route myRoute = new Route("{controller}/{action}/{id}", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary( new {
        controller = "Home", action = "Index", id = UrlParameter.Optional
    })
};
routes.Add("Default", myRoute);

Each Route object defines a URL pattern and describes how to handle requests for such URLs. Table 8-3 shows what this particular entry means.

Table 8-3. Parameters Supplied by the Default Route Entry to an MVC Action Method

URL

Maps to Routing Parameters

/

{ controller = "Home", action = "Index" }

/Forum

{ controller = "Forum", action = "Index" }

/Forum/ShowTopics

{ controller = "Forum", action = "ShowTopics" }

/Forum/ShowTopics/75

{ controller = "Forum", action = "ShowTopics", id = "75" }

There are five properties you can configure on a Route object. These affect whether or not it matches a given URL, and if it does, what happens to the request (see Table 8-4).

Table 8-4. Properties of System.Web.Routing.Route

Property

Meaning

Type

Example

Url

The URL to be matched, with any parameters in curly braces (required).

string

"Browse/{category}/{pageIndex}"

RouteHandler

The handler used to process the request (required).

IRouteHandler

new MvcRouteHandler()

Defaults

Makes some parameters optional, giving their default values. Defaults may include the special value UrlParameter.Optional, which means, "If there's no value in the URL, don't supply any value for this parameter."[a] Later in the chapter, I'll explain more about why this is beneficial.

RouteValueDictionary

new RouteValueDictionary(new {
  controller = "Products",
  action = "List",
  category = "Fish",
  pageIndex = 3 })

Constraints

A set of rules that request parameters must satisfy. Each rule value is either a string (treated as a regular expression) or an IRouteConstraint object.

RouteValueDictionary

new RouteValueDictionary(new {
  pageIndex = @"d{0,6}"
})

DataTokens

A set of arbitrary extra configuration options that are stored with the route entry and will be made available to the route handler (usually not required).

RouteValueDictionary

You'll see how the framework's "areas" feature relies on this later in the chapter.

[a] Technically, this behavior regarding UrlParameter.Optional is implemented by ASP.NET MVC, not by the core routing system, so it wouldn't apply if you used routing with Web Forms or another platform.

Understanding the Routing Mechanism

The routing mechanism runs early in the framework's request processing pipeline. Its job is to take an incoming URL and use it to obtain an IHttpHandler object that will handle the request.

Many newcomers to the MVC Framework struggle with routing. It isn't comparable to anything in earlier ASP.NET technologies, and it's easy to configure wrong. By understanding its inner workings, you'll avoid these difficulties, and you'll also be able to extend the mechanism powerfully to add extra behaviors across your whole application.

The Main Characters: RouteBase, Route, and RouteCollection

Routing configurations are built up of three main elements:

  • RouteBase is the abstract base class for a routing entry. You can implement unusual routing behaviors by deriving a custom type from it (I've included an example near the end of this chapter), but for now you can forget about it.

  • Route is the standard, commonly used subclass of RouteBase that brings in the notions of URL templating, defaults, and constraints. This is what you'll see in most examples.

  • A RouteCollection is a complete routing configuration. It's an ordered list of RouteBase-derived objects (e.g., Route objects).

RouteTable.Routes[47] is a special static instance of RouteCollection. It represents your application's actual, live routing configuration. Typically, you populate it just once, when your application first starts, during the Application_Start() method in Global.asax.cs. It's a static object, so it remains live throughout the application's lifetime, and is not recreated at the start of each request.

Normally, the configuration code isn't actually inline in Application_Start(), but is factored out into a public static method called RegisterRoutes(). That gives you the option of accessing your configuration from unit tests. You'll see a way of unit testing your routing configuration later in this chapter.

How Routing Fits into the Request Processing Pipeline

When a URL is requested, the system invokes each of the IHttpModules registered for the application. One of these is UrlRoutingModule, which for .NET 3.5 applications is referenced directly from your application's Web.config file, and for .NET 4 applications is referenced by the machine-wide ASP.NET Web.config and IIS 7.x applicationHost.config files. This module does three things:

  1. It finds the first RouteBase object in RouteTable.Routes that claims to match this request. Standard Route entries match when three conditions are met:

    • The requested URL follows the Route's URL pattern.

    • All curly brace parameters are present in the requested URL or in the Defaults collection (i.e., so all parameters are accounted for).

    • Every entry in its Constraints collection is satisfied.

      UrlRoutingModule simply starts at the top of the RouteTable.Routes collection and works down through the entries in sequence. It stops at the first one that matches, so it's important to order your route entries most-specific first.

  2. It asks the matching RouteBase object to supply a RouteData structure, which specifies how the request should be handled. RouteData is a simple data structure that has four properties:

    • Route: A reference to the chosen route entry (which is of type RouteBase)

    • RouteHandler: An object implementing the interface IRouteHandler, which will handle the request (in ASP.NET MVC applications, it's usually an instance of MvcRouteHandler[48])

    • Values: A dictionary of curly brace parameter names and values extracted from the request, plus the default values for any optional curly brace parameters not specified in the URL

    • DataTokens: A dictionary of any additional configuration options supplied by the routing entry (you'll hear more about this later, during the coverage of areas)

  3. It invokes RouteData's RouteHandler. It supplies to the RouteHandler all available information about the current request via a parameter called requestContext. This includes the RouteData information and an HttpContextBase object specifying all manner of context information including HTTP headers, cookies, authentication status, query string data, and form post data.

The Order of Your Route Entries Is Important

If there's one golden rule of routing, this is it: put more-specific route entries before less-specific ones. Yes, RouteCollection is an ordered list, and the order in which you add route entries is critical to the route-matching process. The system does not attempt to find the most specific match for an incoming URL (whatever that would mean); its algorithm is to start at the top of the route table, check each entry in turn, and stop when it finds the first match. For example, don't configure your routes as follows:

routes.MapRoute(
  "Default",                                             // Route name
  "{controller}/{action}/{id}",                          // URL with parameters
  new { controller = "Home", action = "Index",           // Parameter defaults
        id = UrlParameter.Optional }
);
routes.MapRoute(
    "Specials",                                             // Route name
    "DailySpecials/{date}",                                 // URL with parameters
    new { controller = "Catalog", action = "ShowSpecials" } // Parameter defaults
);

because /DailySpecials/March-31 will match the top entry, yielding the RouteData values shown in Table 8-5.

Table 8-5. How the Aforementioned Routing Configuration Erroneously Interprets a Request for /DailySpecials/March-31

RouteData Key

RouteData Value

controller

DailySpecials

action

March-31

This is obviously not what you want. Nothing is ever going to get through to CatalogController, because the top entry already catches a wider range of URLs. The solution is to switch the order of those entries. DailySpecials/{date} is more specific than {controller}/{action}/{id}, so it should be higher in the list.

Adding a Route Entry

The default route (matching {controller}/{action}/{id}) is so general in purpose that you could build an entire application around it without needing any otherrouting configuration entry. However, if you do want to handle URLs that don't bear any resemblance to the names of your controllers or actions, then you will need other configuration entries.

Starting with a simple example, let's say you want the URL /Catalog to lead to a list of products. You may have a controller class called ProductsController, itself having an action method called List(). In that case, you'd add this route:

routes.Add(new Route("Catalog", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary(
        new { controller = "Products", action = "List" }
    )
});

This entry will match /Catalog or /Catalog?some=querystring, but not /Catalog/Anythingelse. To understand why, let's consider which parts of a URL are significant to a Route entry.

URL Patterns Match the Path Portion of a URL

When a Route object decides whether it matches a certain incoming URL, it only considers the path portion of that incoming URL. That means it doesn't consider the domain name (also called host) or any query string values. Figure 8-1 depicts the path portion of a URL.[49]

Identifying the path portion of a URL

Figure 8-1. Identifying the path portion of a URL

Continuing the previous example, the URL pattern "Catalog" would therefore match both http://example.com/Catalog and https://a.b.c.d:1234/Catalog?query=string.

If you deploy to a virtual directory, your URL patterns are understood to be relative to that virtual directory root. For example, if you deploy to a virtual directory called virtDir, the same URL pattern ("Catalog") would match http://example.com/virtDir/Catalog. Of course, it could no longer match http://example.com/Catalog, because IIS would no longer ask your application to handle that URL.

Meet RouteValueDictionary

Notice that a Route's Defaults property is a RouteValueDictionary. It exposes a flexible API, so you can populate it in several ways according to your preferences. The previous code sample in this chapter uses a C# 3 anonymous type. The RouteValueDictionary will extract its list of properties (here, controller and action) at runtime, so you can supply any arbitrary list of name/value pairs. It's a tidy syntax.

A different technique to populate a RouteValueDictionary is to supply an IDictionary<string, object> as a constructor parameter, or alternatively to use a collection initializer, as in the following example:

routes.Add(new Route("Catalog", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary
    {
        { "controller", "Products" },
        { "action", "List" }
    }
});

Either way, RouteValueDictionary is ultimately just a dictionary, so it's not very type-safe and offers no IntelliSense—so there's nothing to stop you from mistyping conrtoller, and you won't find out until an error occurs at runtime.

Take a Shortcut with MapRoute()

ASP.NET MVC adds an extension method to RouteCollection, called MapRoute(). This provides an alternative syntax for adding route entries. You might find it more convenient than calling routes.Add(new Route(...)). You could express the same route entry as follows:

routes.MapRoute("PublicProductsList", "Catalog",
                new { controller = "Products", action = "List" });

In this case, PublicProductsList is the name of the route entry. It's just an arbitrary unique string. That's optional: route entries don't have to be named (when calling MapRoute(), you can pass null for the name parameter). However, if you do give names to certain route entries, that gives you a different way of referring to them when testing or when generating outbound URLs. My personal preference is not to give names to my routes, as I'll explain later in this chapter.

Note

You can also give names to route entries when calling routes.Add() by using the method overload that takes a name parameter.

Using Parameters

As you've seen several times already, parameters can be accepted via acurly brace syntax. Let's add aparameter called color to our route:

routes.Add(new Route("Catalog/{color}", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary(
        new { controller = "Products", action = "List" }
    )
});

Or, equivalently:

routes.MapRoute(null, "Catalog/{color}",
                new { controller = "Products", action = "List" });

This route will now match URLs such as /Catalog/yellow or /Catalog/1234, and the routing system will add a corresponding name/value pair to the request's RouteData object. On a request to /Catalog/yellow, for example, RouteData.Values["color"] would be given the value yellow.

Tip

Since Route objects use curly braces (i.e., { and }) as the delimiters for parameters, you can't use curly braces as normal characters in URL patterns. If you do want to use curly braces as normal characters in a URL pattern, you must write {{ and }}—double curly braces are interpreted as a single literal curly brace. But seriously, when would you want to use curly braces in a URL?

Receiving Parameter Values in Action Methods

You know that action methods can take parameters. When ASP.NET MVC wants to call one of your action methods, it needs to supply a value for each method parameter. One of the places where it can get values is the RouteData collection. It will look in RouteData's Values dictionary, aiming to find a key/value pair whose name matches the parameter name.

So, if you have an action method like the following, its color parameter would be populated according to the {color} segment parsed from the incoming URL:

public ActionResult List(string color)
{
    // Do something
}

Therefore, you rarely need to retrieve incoming parameters from the RouteData dictionary directly (i.e., action methods don't normally need to access RouteData.Values["somevalue"]). By having action method parameters with matching names, you can count on them being populated with values from RouteData, which are the values parsed from the incoming URL.

To be more precise, action method parameters aren't simply taken directly from RouteData.Values, but instead are fetched via the model binding system, which is capable of instantiating and supplying objects of any .NET type, including arrays and collections. You'll learn more about this mechanism in Chapters 9 and 12.

Using Defaults

You didn't give a default value for {color}, so it became a mandatory parameter. The Route entry no longer matches a request for /Catalog. You can make the parameter optional by adding to your Defaults object:

routes.Add(new Route("Catalog/{color}", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary(
        new { controller = "Products", action = "List", color=(string)null }
    )
});

Or, equivalently:

routes.MapRoute(null, "Catalog/{color}",
    new { controller = "Products", action = "List", color = (string)null }
);

Note

When you construct an anonymously typed object, theC# compiler has to infer the type of each property from the value you've given. The value null isn't of any particular type, so you have to cast it to something specific or you'll get a compiler error. That's why it's written (string)null.

Now this Route entry will match both /Catalog and /Catalog/orange. For /Catalog, RouteData.Values["color"] will be null, while for /Catalog/orange, RouteData.Values["color"] will equal "orange".

If you want a non-null default value, as you must for nonnullable types like int, you can specify that in the obvious way:

routes.Add(new Route("Catalog/{color}", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary(
        new { controller = "Products", action = "List", color = "Beige", page = 1 }
    )
});

Notice here that we're specifying "default" values for some "parameters" that don't actually correspond to any curly brace parameters in the URL (i.e., controller, action, and page, even though there's no {controller}, {action}, or {page} in the URL pattern). That's a perfectly fine thing to do; it's the correct way to set up RouteData values that are actually fixed for a given Route entry. For example, for this Route object, RouteData["controller"] will always equal "Products", regardless of the incoming URL, so matching requests will always be handled by ProductsController.

Remember that when you use MvcRouteHandler (as you do by default in ASP.NET MVC), you must have a value called controller; otherwise, the framework won't know what to do with the incoming request and will throw an error. The controller value can come from a curly brace parameter in the URL, or can just be specified in the Defaults object, but it cannot be omitted.

Creating Optional Parameters with No Default Value

As you've seen from the default routing configuration, it's possible to use the special default value UrlParameter.Optional instead of giving an actual default value for a parameter—for example:

routes.MapRoute(null, "Catalog/{page}",
    new { controller = "Products", action = "List", page = UrlParameter.Optional }
);

This is a way of saying that if the incoming URL has a page value, then we should use it, but if the URL doesn't have a page value, then routing shouldn't supply any page parameter to the action method.

You might be wondering why this is different or better than using 0 or null as the default value for page. Here are two reasons:

  • If your action method takes a page parameter of type int, then because that type can't hold null, you would have to supply the default value of 0 or some other int value. This means the action method would now always receive a legal value for page, so you wouldn't be able to control the default value using the MVC Framework's [DefaultValue] attribute or C# 4's optional parameter syntax on the action method itself (you'll learn more about these in the next chapter).

  • Even if your action's page parameter was nullable, there's a further limitation.When binding incoming data to action method parameters, the MVC Framework prioritizes routing parameter values above query string values (you'll learn more about value providers and model binding in Chapter 12). So, any routing value for page—even if it's null—would take priority and hide any query string value called page.

UrlParameter.Optional eliminates both of these limitations. If the incoming URL contains no value for that parameter, then the action method won't receive any routing parameter of that name, which means it's free to obtain a value from [DefaultValue] or the query string (or from anywhere else).

Tip

It's generally easier and more flexible to control parameter defaults directly on your action method code using [DefaultValue] or C# 4's optional parameter syntax, as you'll learn in the next chapter. So, if your goal is to say that a certain parameter may or may not be included in the URL, then it's usually preferable to use UrlParameter.Optional in your routing configuration than to specify an explicit default value there.

Using Constraints

Sometimes you will want to add extra conditions that must be satisfied for a request to match a certain route—for example:

  • Some routes should only match GET requests, not POST requests (or vice versa).

  • Some parameters should match certain patterns (e.g., "The ID parameter must be numeric").

  • Some routes should match requests made by regular web browsers, while others should match the same URL being requested by an iPhone.

In these cases, you'll use the Route's Constraints property. It's another RouteValueDictionary,[50] in which the dictionary keys correspond to parameter names and values correspond to constraint rules for that parameter. Each constraint rule can be a string, which is interpreted as a regular expression; or, for greater flexibility, it can be a custom constraint of type IRouteConstraint. Let's see some examples.

Matching Against Regular Expressions

To ensure that a parameter is numeric, you'd use a rule like this:

routes.Add(new Route("Articles/{id}", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary(
            new { controller = "Articles", action = "Show" }
        ),
    Constraints = new RouteValueDictionary(new { id = @"d{1,6}" })
});

Or, equivalently, this:

routes.MapRoute(null, "Articles/{id}",
    new { controller = "Articles", action = "Show" },
    new { id = @"d{1,6}" }
);

This validation rule tests any potential id value against the regular expression d{1,6}, which means that it's numeric and one to six digits long. This Route would therefore match /Articles/1 and /Articles/123456, but not /Articles (because there's no Default value for id), /Articles/xyz, or /Articles/1234567.

Warning

When writing regular expressions in C#, remember that the backslash character has a special meaning both to the C# compiler and in regular expression syntax. You can't simply write "d" as a regular expression to match a digit—you must write "\d" (the double backslash tells the C# compiler to output a single backslash followed by a d, rather than an escaped d), or @"d" (the @ symbol disables the compiler's escaping behavior for that string literal).

Matching HTTP Methods

If you want your Route to match only GET requests (not POST requests), you canuse the built-in HttpMethodConstraint class (it implements IRouteConstraint)—for example:

routes.Add(new Route("Articles/{id}", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary(
            new { controller = "Articles", action = "Show" }
    ),
    Constraints = new RouteValueDictionary(
        new { httpMethod = new HttpMethodConstraint("GET") }
    )
});

Or slightly more concisely, using MapRoute():

routes.MapRoute(null, "Articles/{id}",
    new { controller = "Articles", action = "Show" },
    new { httpMethod = new HttpMethodConstraint("GET") }
);

If you want to match any of a set of possible HTTP methods, pass them all into HttpMethodConstraint's constructor—for example, new HttpMethodConstraint("GET", "DELETE").

Tip

HttpMethodConstraint works no matter what key value it has in the Constraints dictionary, so in this example you can replace httpMethod with any other key name. It doesn't make any difference.

Note that HttpMethodConstraint is totally unrelated to the [HttpGet] and [HttpPost] attributes you've used in previous chapters, even though it's concerned with whether to accept GET requests or POST requests. The difference is

  • HttpMethodConstraint works at the routing level, affecting which route entry a given request should match.

  • [HttpGet], [HttpPost], and related attributes run much later in the pipeline, when a route has been matched, a controller has been instantiated and invoked, and the controller is deciding which of its action methods should process the request.

If your goal is to control whether one specific action method handles GET requests or POST requests, then use [HttpGet] and [HttpPost], because attributes are easy to manage and can directly target one specific action method, whereas if you keep adding route constraints, you'll cause an unmanageable buildup of complexity in your global routing configuration. You'll learn more about handling different HTTP methods—including exotic ones such as PUT and DELETE that browsers can't normally perform—in Chapter 10.

Matching Custom Constraints

If you want to implement constraints that aren't merely regular expressions on URL parameters or restrictions on HTTP methods, you can implement your own IRouteConstraint. This gives you great flexibility to match against any aspect of the request context data.

For example, if you want to set up a route entry that matches only requests from certain web browsers, you could create the following custom constraint. The interesting lines are the bold ones:

public class UserAgentConstraint : IRouteConstraint
{
    private string _requiredSubstring;
    public UserAgentConstraint(string requiredSubstring)
    {
        this._requiredSubstring = requiredSubstring;
    }

    public bool Match(HttpContextBase httpContext, Route route, string paramName,
                      RouteValueDictionary values, RouteDirection routeDirection)
    {
        if (httpContext.Request.UserAgent == null)
            return false;
        return httpContext.Request.UserAgent.Contains(_requiredSubstring);
    }
}

Note

The routeDirection parameter tells you whether you're matching against an inbound URL (RouteDirection.IncomingRequest) or about to generate an outbound URL (RouteDirection.UrlGeneration). For consistency, it normally makes sense to ignore this parameter.

The following route entry will only match requests coming from aniPhone:

routes.Add(new Route("Articles/{id}", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary(
        new { controller = "Articles", action = "Show" }
    ),
    Constraints = new RouteValueDictionary(
        new { id = @"d{1,6}", userAgent = new UserAgentConstraint("iPhone") }
    )
});

Prioritizing Controllers by Namespace

Normally, when an incoming request matches a particular route entry, the MVC Framework takes the controller parameter (either from a curly brace {controller} parameter in the URL pattern or from the route entry's Defaults collection), and then looks for any controller class with a matching name. For example, if the incoming controller valuewas products, it would look for a controller class called ProductsController (case insensitively). There has to be only one matching controller among all your referenced assemblies—if there are two or more, it will fail, reporting that there was an ambiguous match.

If you want to make ASP.NET MVC prioritize certain namespaces when choosing a controller to handle a request, you can pass an extra parameter called namespaces.

routes.MapRoute(null, "Articles/{id}",
    new { controller = "Articles", action = "Show" },
    new[] { "MyApp.Controllers", "AnotherAssembly.Controllers" }
);

Now, it doesn't matter if there are other ArticlesController classes in other namespaces—it will try to find a class in any of the explicitly chosen namespaces. Only if there is no matching class in the chosen namespaces will it revert to the usual behavior of finding one from all other namespaces.

Internally, this MapRoute() overload is equivalent to writing the following:

routes.Add(new Route("Articles/{id}", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary(
            new { controller = "Articles", action = "Show" }
    ),
    DataTokens = new RouteValueDictionary(
        new { Namespaces = new[] { "MyRoutingApp.Controllers",
                                   "AnotherAssembly.Controllers" } }
    )
});

You'll learn more about how DataTokens["Namespaces"] underpins the notion of areas later in this chapter, and more about how controller factories respond to this option in Chapter 10.

Accepting a Variable-Length List of Parameters

So far, you've seen how to acceptonly a fixed number of curly brace parameters on each route entry. But what if you want to create the impression of an arbitrary directory structure, so you could have URLs such as /Articles/Science/Paleontology/Dinosaurs/Stegosaurus? How many curly brace parameters will you put into the URL pattern?

The routing system allows you to define catchallparameters, which ignore slashes and capture everything up to the end of a URL. Designate a parameter as being catchall by prefixing it with an asterisk (*). Here's an example:

routes.MapRoute(null, "Articles/{*articlePath}",
    new { controller = "Articles", action = "Show" }
);

This route entry would match /Articles/Science/Paleontology/Dinosaurs/Stegosaurus, yielding the route values shown in Table 8-6.

Table 8-6. RouteData Values Prepared by This Catchall Parameter

RouteData Key

RouteData Value

controller

Articles

action

Show

articlePath

Science/Paleontology/Dinosaurs/Stegosaurus

Naturally, you can only have one catchall parameter in a URL pattern, and it must be the last (i.e., rightmost) thing in the URL, since it captures the entire URL path from that point onward. However, it still doesn't capture anything from the query string. As mentioned earlier, Route objects only look at the path portion of a URL.

Catchall parameters are useful if you're letting visitors navigate through some kind of arbitrary depth hierarchy, such as in a content management system (CMS).

Matching Files on the Server's Hard Disk

The whole goal of routing is to break the one-to-one association between URLs and files in the server's file system. However, the routing system still does check the file system to see if an incoming URL happens to match a file or disk, and if so, routing ignores the request (bypassing any route entries that the URL might also match) so that the file will be served directly.

This is very convenient for static files, such as images, CSS files, and JavaScript files. You can keep them in your project (e.g., in your /Content or /Script folders), and then reference and serve them directly, just as if you were not using routing at all. Since the file genuinely exists on disk, that takes priority over your routing configuration.

Using the RouteExistingFiles Flag

If instead you want your routing configuration to take priority over files on disk, you can set the RouteCollection's RouteExistingFiles property to true. (It's false by default.)

public static void RegisterRoutes(RouteCollection routes)
{
    // Before or after adding route entries, you can set this:
    routes.RouteExistingFiles = true;
}

When RouteExistingFiles is true, the routing system does not care whether a URL matches an actual file on disk; it attempts to find and invoke the matching RouteTable.Routes entry regardless. When this option is enabled, there are only two possible reasons for a file to be served directly:

  • When an incoming URL doesn't match any route entry, but it does match a file on disk.

  • When you've used IgnoreRoute() (or have some other route entry based on StopRoutingHandler). See the following discussion for details.

Setting RouteExistingFiles to true is a pretty drastic option, and isn't what you want in most cases. For example, notice that a route entry for {controller}/{action} also matches /Content/styles.css. Therefore, the system will no longer serve that CSS file, and will instead return an error message saying that it can't find a controller class called ContentController.

Note

RouteExistingFiles is a feature of the routing system, so it only makes a difference for requests where the routing system is active (i.e., for requests passing through UrlRoutingModule). For IIS 7 or later in integrated pipeline mode, and for IIS 6 with a suitable wildcard map, that includes every request. But in other deployment scenarios (e.g., IIS 6 without a wildcard map), IHttpModules only get involved when the URL appears to have a relevant extension (e.g., *.aspx, *.ashx), so requests for *.css (and other such nondynamic files) don't pass through routing, and are served statically regardless of RouteExistingFiles. You'll learn more about wildcard maps and the differences between IIS 6 and IIS 7 in Chapter 16.

Using IgnoreRoute to Bypass the Routing System

If you want to set up specific exclusions in the URL space, preventing certain patterns from being matched by the routing system,[51] you can use IgnoreRoute()—for example:

public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{filename}.xyz");

    // Rest of routing config goes here
}

Here, {filename}.xyz is treated as a URL pattern just like in a normal route entry, so in this example, the routing system will now ignore any requests for /blah.xyz or /foo.xyz?some=querystring. (Of course, you must place this entry higher in the route table than any other entry that would match and handle those URLs.) You can also pass a constraints parameter if you want tighter control over exactly which URLs are ignored by routing.

IgnoreRoute() is helpful if

  • You have a special IHttpHandler registered to handle requests for *.xyz, and you don't want the routing system to interfere. (The default ASP.NET MVC project uses this technique to protect requests for *.axd from interference.)

  • You have set RouteExistingFiles to true, but you also want to set up an exception to that rule (e.g., so that all files under /Content are still served directly from disk). In that case, you can use routes.IgnoreRoute("Content/{*restOfUrl}").

Tip

In many applications, there's no need to use IgnoreRoute() (though you probably want to leave the default exclusion of *.axd in place). Don't waste your time specifically trying to exclude portions of the URL space unless you've got a good reason to. Unless an incoming URL actually matches one of your route entries, the system will just issue a 404 Not Found error anyway.

How does this work? Internally, IgnoreRoute() sets up a route entry whose RouteHandler is an instance of StopRoutingHandler (rather than MvcRouteHandler). In fact, the example shown here is exactly equivalent to writing the following:

routes.Add(new Route("{filename}.xyz", new StopRoutingHandler()));

The routing system is hard-coded to look out for StopRoutingHandler and recognizes it as a signal to bypass routing. You can use StopRoutingHandler as the route handler in your own custom routes and RouteBase classes if you want to set up more complicated rules for not routing certain requests.

Generating Outgoing URLs

Handling incoming URLs is only half of the story. Your site visitor will need to navigate from one part of your application to another, and for them to do that, you'll need to provide them with links to other valid URLs within your application's URL schema.

The old-fashioned way to supply links is simply to build them with string concatenations and hard-code them all around your application. This is what we've done for years in ASP.NET Web Forms and most other web application platforms. You, the programmer, know there's a page called Details.aspx looking for a query string parameter called id, so you hard-code a URL like this:

myHyperLink.NavigateUrl = "~/Details.aspx?id=" + itemID;

The equivalent in an MVC view would be a line like this:

<a href="/Products/Details/<%: ViewData["ItemID"] %>">More details</a>

That URL will work today, but what about tomorrow when you refactor and want to use a different URL for ProductsController or its Details action? All your existing links will be broken. And what about constructing complex URLs with multiple parameters including special characters—do you always remember to escape them properly?

Fortunately, the routing system introduces a better way. Since your URL schema is explicitly known to the framework, and held internally as a strongly typed data structure, you can take advantage of various built-in API methods to generate perfectly formed URLs without hard-coding. The routing system can reverse-engineer your active routing configuration, calculating at runtime what URL would lead the visitor to a specific controller and action method, and how to embed any other parameters into the URL.

Generating Hyperlinks with Html.ActionLink()

The simplest way to generate a URL and render it in a normal HTML hyperlink is to call Html.ActionLink() from a view template—for example:

<%: Html.ActionLink("See all of our products", "List", "Products") %>

will render an HTML hyperlink to whatever URL, under your current routing configuration, goes to the List action on your controller class ProductsController. Under the default routing configuration, it therefore renders

<a href="/Products/List">See all of our products</a>

Note that if you don't specify a controller (i.e., if you call Html.ActionLink("See all of our products", "List")), then by default it assumes that you're referring to another action on the same controller currently being executed.

That's a lot cleaner than hard-coded URLs and raw string manipulation.Most importantly, it solves the problem of changing URL schema. Any changes to your routing configuration will be reflected immediately by any URLs generated this way.

It's also better from a separation-of-concerns perspective. As your application grows, you might prefer to consider routing (i.e., the business of choosing URLs to identify controllers and actions) a totally separate concern from placing everyday links and redirections between views and actions. Each time you place a link or redirection, you don't want to think about URLs; you only want to think about which action method the visitor should end up on. Automatic outbound URL generation helps you to avoid muddling these concerns—minimizing your mental juggling.

Passing Extra Parameters

You can pass extra custom parameters that are needed by the route entry:[52]

<%: Html.ActionLink("Red items", "List", "Products",
                     new { color="Red", page=2 }, null) %>

Under the default routing configuration, this will render

<a href="/Products/List?color=Red&amp;page=2">Red items</a>

Note

The ampersand in the URL is encoded as &amp;, which is necessary for the document to be valid XHTML. (In XML, & signals the beginning of an XML entity reference.) The browser will interpret &amp; as &, so when the user clicks the link, the browser will issue a request to /Products/List?color=Red&page=2.

Or, if your routing configuration contains a route to Products/List/{color}/{page}, then the same code would render

<a href="/Products/List/Red/2">Red items</a>

Notice that outbound routing prefers to put parameters into the URL as long as there's a curly brace parameter with a matching name. However, if there isn't a corresponding curly brace parameter, it falls back on appending a name/value pair to the query string.

Just like inbound route matching, outbound URL generation always picks the first matching route entry. It does not try to find the most specific matching route entry (e.g., the one with the closest combination of curly brace parameters in the URL). It stops as soon as it finds any RouteBase object that will provide a URL for the supplied routing parameters. This is another reason to make sure your more specific route entries appear before more general ones! You'll find further details about this algorithm later in the chapter.

Note

RouteBase objects enforce constraints as part of outbound URL generation as well as inbound URL matching. For example, if this route entry had the constraint page = @"d+", then it would accept 1234 (either as a string or as an int) for its page parameter, but it wouldn't accept 123x.

How Parameter Defaults Are Handled

If you link to a parameter value that happens to be equal to the default value for that parameter (according to whichever route entry was matched), then the system tries to avoid rendering it into the URL. That means you can get cleaner, shorter URLs—for example:

<%: Html.ActionLink("Products homepage", "Index", "Products") %>

will render the following (assuming that Index is the default value for action):

<a href="/Products">Products homepage</a>

Notice the URL generated here is /Products, not /Products/Index. There would be no point putting Index in the URL, because that's configured as the default anyway.

This applies equally to all parameters with defaults (as far as routing is concerned, there's nothing special about parameters called controller or action). Of course, it can only omit a continuous sequence of default values from the right-hand end of the URL string, not individual ones from the middle of the URL (or else you'd get malformed URLs).

Generating Fully Qualified Absolute URLs

Html.ActionLink() usually generates only the path portion of a URL (i.e., /Products, not http://www.example.com/Products). However, it also has a few overloads that generate fully qualified absolute URLs. The most complete, full-fat, supersized overload is as follows:

<%: Html.ActionLink("Click me", "MyAction", "MyController", "https",
                    "www.example.com", "anchorName", new { param = "value" },
                    new { myattribute = "something" }) %>

Hopefully you won't need to use this scary-looking helper very often, but if you do, it will render the following:

<a myattribute="something"
   href="https://www.example.com/MyController/MyAction?param=value#anchorName">
Click me</a>

If you deploy to a virtual directory, then that directory name will also appear at the correct place in the generated URL.

Note

The routing system in System.Web.Routing has no concept of fully qualified absolute URLs; it only thinks about virtual paths (i.e., the path portion of a URL, relative to your virtual directory root). The absolute URL feature demonstrated here is actually added by ASP.NET MVC in its wrapper methods.

Generating Links and URLs from Pure Routing Data

You know that the routing system isn't intended only for ASP.NET MVC, so it doesn't give special treatment to parameters called controller or action. However, all the URL-generating methods you've seen so far do require you to specify an explicit action method (e.g., Html.ActionLink() always takes an action parameter).

Sometimes it's handy not to treat controller or action as special cases, but simply to treat them just like any other routing parameter. For example, in Chapter 5, the navigation links were built from NavLink objects that just held arbitrary collections of routing data. For these scenarios, there are alternative URL-generating methods that don't force you to treat controller or action as special cases. They just take an arbitrary collection of routing parameters and match that against your routing configuration.

Html.RouteLink() is the equivalent of Html.ActionLink()—for example:

<%: Html.RouteLink("Click me", new { controller = "Products", action = "List" }) %>

will render the following (under the default routing configuration):

<a href="/Products/List">Click me</a>

Similarly, Url.RouteUrl() is equivalent to Url.Action(). For example, under the default URL configuration

<%: Url.RouteUrl(new { controller = "Products", action = "List" }) %>

will render the following URL:

/Products/List

Note that this is just a URL string, not a complete HTML <a> tag.

In ASP.NET MVC applications, these methods aren't often needed. However, it's good to know that you have such flexibility if you do need it, or if it simplifies your code (as it did in Chapter 5).

Performing Redirections to Generated URLs

The most common reason to generate URLs is to render HTML hyperlinks. The second most common reason is when an action method wants to issue an HTTP redirection command, which instructs the browser to move immediately to some other URL in your application.

To issue an HTTP redirection, simply return the result of RedirectToAction(), passing it the target controller and action method:

public ActionResult MyActionMethod()
{
    return RedirectToAction("List", "Products");
}

This returns a RedirectToRouteResult object, which, when executed, uses the URL-generating methods internally to find the correct URL for those route parameters, and then issues an HTTP 302 redirection to it. As usual, if you don't specify a controller (e.g., return RedirectToAction("List")), it will assume you're talking about another action on the same controller that is currently executing.

Alternatively, you can specify an arbitrary collection of routing data using RedirectToRoute():

public ActionResult MyActionMethod()
{
    return RedirectToRoute(new { action = "SomeAction", customerId = 456 });
}

Note

When the server responds with an HTTP 302 redirection, no other HTML is sent in the response stream to the client. Therefore, you can only call RedirectToAction() from an action method, not in a view page like you might call Html.ActionLink()—it doesn't make sense to imagine sending a 302 redirect in the middle of a page of HTML. You'll learn more about the two main types of HTTP redirections (301s and 302s) later in this chapter.

If, rather than performing an HTTP redirection, you simply want to obtain a URL as a string, you can call Url.Action() or Url.RouteUrl() from your controller code—for example:

public ActionResult MyActionMethod()
{
    string url = Url.Action("SomeAction", new { customerId = 456 });
    // ... now do something with url
}

Understanding the Outbound URL-Matching Algorithm

You've now seen a lot of examples of generating outbound URLs. But routing configurations can contain multiple entries, so how does the framework decide which route entry to use when generating a URL from a given set of routing values? The actual algorithm has a few subtleties that you wouldn't guess, so it's helpful to have the details on hand in case you hit any surprising behavior.

Just like inbound route matching, it starts at the top of the route table and works down in sequence until it hits the first RouteBase object that returns a non-null URL for the supplied collection of routing values. Standard Route objects will return a non-null URL only when these three conditions are met:

  1. The Route object must be able toobtain a value for each of its curly brace parameters. It will take values from any of the following three collections, in this order of priority:

    1. Explicitly provided values (i.e., parameter values that you supplied when calling the URL-generating method).

    2. RouteData values from the current request (except for ones that appear later in the URL pattern than any you've explicitly supplied new values for). This behavior will be discussed in more detail shortly.

    3. Its Defaults collection.

  2. None of the explicitly provided parameter values may disagree with the Route object's default-only parameter values. A default-only parameter is one that appears in the entry's Defaults collection, but does not appear as a curly brace parameter in the URL pattern. Since there's no way of putting a nondefault value into the URL, the route entry can't describe a nondefault value, and therefore refuses to match.

  3. None of the chosen parameter values (including those inherited from the current request's RouteData) may violate any of the Route object's Constraints entries.

The first Route object meeting these criteria will produce a non-null URL, and that will terminate the URL-generating process. The chosen parameter values will be substituted in for each curly brace placeholder, with any trailing sequence of default values omitted. If you've supplied any explicit parameters that don't correspond to curly brace or default parameters, then it will render them as a set of query string name/value pairs.

Just to make it ridiculously clear, the framework doesn't try to pick the most specific route entry or URL pattern. It stops when it finds the first one that matches; so follow the golden rule of routing—put more specific entries above less specific ones! If a certain entry matches when you don't want it to, you must either move it further down the list or make it even more specific (e.g., by adding constraints or removing defaults) so that it no longer matches when you don't want it to.

Note

To support the areas feature, any parameter with the name area has a special meaning. You'll learn more about how areas interact with outbound routing later, but for now just understand that area is a reserved routing parameter name and can't be used for other purposes.

Generating Hyperlinks with Html.ActionLink<T> and Lambda Expressions

Using Html.ActionLink() is better than using hard-coded string manipulations, but you could still argue that it's not especially type-safe. There's no IntelliSense to help you specify an action name or pass the correct set of custom parameters to it.

The MVC Futures assembly, Microsoft.Web.Mvc.dll,[53] contains a generic overload, Html.ActionLink<T>(). Here's how it looks:

<%: Html.ActionLink<ProductsController>(x =>x.List(), "All products") %>

This would render the following (under the default routing configuration):

<a href="/Products/List">All products</a>

This time, the generic ActionLink<T>() method takes a generic parameter T specifying the type of the target controller, and then the action method is indicated by a lambda expressionacting on that controller type. The lambda expression is never actually executed. During compilation, it becomes a data structure that the routing system can inspect at runtime to determine what method and parameters you're referencing.

Note

For this to work, your view template needs to import whatever namespace ProductsController lives in, plus the namespace Microsoft.Web.Mvc. For example, you can add <%@ Import Namespace="..." %> directives at the top of your ASPX view file.

With Html.ActionLink<T>(), you get a strongly typed interface to your URL schema with full IntelliSense. Most newcomers imagine that this is hugely advantageous, but actually it brings both technical and conceptual problems:

  • You have to keep importing the correct namespaces to each view template.

  • Html.ActionLink<T>() creates the impression that you can link to any method on any controller. However, sometimes that's impossible, because your routing configuration might not define any possible route to it, or the URL generated might actually lead to a different action method overload. Html.ActionLink<T>() can be misleading.

  • Strictly speaking, controller actions are named pieces of functionality, not C# methods. ASP.NET MVC has several layers of extensibility (e.g., method selector attributes), which means that an incoming action name might be handled by a C# method with a totally unrelated name (you'll see these demonstrated in Chapter 10). Lambda expressions cannot represent this, so Html.ActionLink<T>() cannot be guaranteed to work properly.

It would be great if Html.ActionLink<T>() could be guaranteed to work properly, because the benefits of a strongly typed API and IntelliSense are compelling indeed. However, there are many scenarios in which it cannot work, and that's why the MVC team put this helper into the MVC Futures assembly, not the ASP.NET MVC core package. Most ASP.NET MVC developers prefer to stick to the regular string-based overloads of Html.ActionLink().

Working with Named Routes

You can give each route entry a unique name—for example:

routes.Add("intranet", new Route("staff/{action}", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary(new { controller = "StaffHome" })
});

Or equivalently, using MapRoute():

routes.MapRoute("intranet", "staff/{action}", new { controller = "StaffHome" });

Either way, this code creates a named route entry called intranet. Everyone seems to think it's a good idea to gives names to their children, but what's the point of giving names to our route entries? In some cases, it can simplify outbound URL generation. Instead of having to put your route entries in the right order so the framework will pick the right one automatically, you can just specify which one you want by name. You can specify a route name when calling Url.RouteUrl() or Html.RouteLink()—for example:

<%: Html.RouteLink("Click me", "intranet", new { action = "StaffList" }) %>

This will generate

<a href="/staff/StaffList">Click me</a>

regardless of any other entries in your routing configuration.

Without named routes, it can be difficult to make sure that both inbound and outbound routing always select exactly the route you want. Sometimes it seems that the correct priority order for inbound matching conflicts with the correct priority order for outbound URL generation, and you have to figure out what constraints and defaults give the desired behavior. Naming your routes lets you stop worrying about ordering and directly select them by name. At times, this obviously can be advantageous.

Why You Might Not Want to Use Named Routes

Remember that one of the benefits of outbound URL generation is supposed to be separation of concerns. Each time you place a link or redirection, you don't want to think about URLs; you only want to think about which action the visitor should end up on. Unfortunately, named routes undermine this goal because they force you to think about not just the destination of each link (i.e., which action), but also the mechanism of reaching it (i.e., which route entry).

If you can avoid giving names to your route entries, you'll have a cleaner system overall. You won't have to remember or manage the names of your route entries, because they're all anonymous. When placing links or redirections, you can just specify the target action, letting the routing system deal with URLs automatically. If you wish, you can make a set of unit tests that verify both inbound matching and outbound URL generation (as you'll see at the end of this chapter), thinking of that task as a stand-alone concern.

Whether or not to use named routes is of course a matter of personal preference. Either way, it's better than hard-coding URLs!

Working with Areas

As you know, Visual Studio allows you to break down a large software solution into multiple projects—it's a way of keeping things organized by putting up solid boundaries between different concerns. But what if your ASP.NET MVC project alone gets too big for comfort? Medium to large ASP.NET MVC applications might easily have more than 20 controllers, each of which has 5 or more views or partials, along with its own collection of view models and perhaps specialized utility classes and HTML helpers.

By default ASP.NET MVC gives you a folder for controllers, another folder for views, another folder for view models, and so on. But unless you can keep track of how each item relates to a specific area of application functionality, it's hard to remember what each item is there for. Are all of them being used, and by what? What if there are two different but similarly named controllers? If the environment starts to feel messy, people lose the motivation to be tidy.

To reduce this difficulty, ASP.NET MVC lets you organize your project into areas. Each area is supposed to represent a functional segment of your application (e.g., administration, reporting, or a discussion forum), and is a package of controllers, views, routing entries, other .NET classes, JavaScript files, and so on. This high-level grouping offers a number of benefits:

  • Organization: Each area has its own separate directory structure, so it's easy to see how those controllers, views, etc., relate to a particular application feature.

  • Isolation: If you have multiple teams of developers working concurrently, they can each focus on a separate area. The teams won't interfere with one another so often; they can each choose their own controller names and routing configurations without fear of clashes or ambiguities. Less e-mail, less time spent in cross-team meetings—isn't that what we all want?

  • Reuse: Each area can be largely agnostic toward its host project and its sibling areas. This allows for a level of portability: in theory, you could duplicate an area from a previous project to reuse in your current one. In practice, only a minority of areas will have such stand-alone functionality that they would usefully apply to unrelated projects.

Most of the magic of areas has to do with URLs and routing, which is why I'm covering them in this chapter. In a smaller way, areas affect view engines too, as you'll soon learn.

Setting Up Areas

The easiest way to add a new area to your ASP.NET MVC project is to right-click the project name in Solution Explorer and choose Add

Setting Up Areas
Visual Studio's prompt for an area name

Figure 8-2. Visual Studio's prompt for an area name

Once you enter a name, Visual Studio will add to your project a new top-level folder called Areas (unless that folder already exists), and inside that folder it will prepare a directory structure for the new area.

For example, call your area Admin. Figure 8-3 shows what you'll get.

Folder structure created for a new area called Admin

Figure 8-3. Folder structure created for a new area called Admin

Obviously, the folders Controllers, Models, and Views are area-specific versions of the equivalent folders that you normally have in your project's root folder. I'll explain AdminAreaRegistration.cs in just a moment.

The workflow for adding functionality to an area is exactly like the workflow when building your top-level ASP.NET MVC project.

  • You can add controllers to an area by either right-clicking the /Areas/areaName/Controllers folder and choosing Add

    Folder structure created for a new area called Admin
  • You can add views either by right-clicking inside an action method and choosing Add View or by manually creating an MVC View Page at the conventional location /Areas/areaName/Views/controllerName/actionName.aspx.

  • You're free to add any other set of .NET classes, subfolders, master pages, partials, or static file resources. For example, you could create a folder called Content inside your area folder to hold JavaScript and CSS files used by your area.

Continuing the example, you could add the following controller to the Adminarea:

namespace MyAppName.Areas.Admin.Controllers
{
    public class StatsController : Controller
    {
        public ViewResult Index()
        {
            // To do: Generate some stats for display
            return View();
        }
    }
}

Warning

It's important that you don't change the namespace that this controller lives in. The namespace is the only thing that associates this controller with the Admin area. After compilation, it's no longer in the /Areas/Admin/Controllers folder—it becomes just a type in your project's .NET assembly.

Now, assuming you also create a view for the Index action (which Visual Studio will automatically place at /Areas/Admin/Views/Stats/Index.aspx), you'll be able to run the action by browsing either to /Admin/Stats/Index or /Admin/Stats.

Note

The framework's built-in default view engine understands areas, and will look for views, masters, and partials in /Areas/areaName/Views/Stats or /Areas/areaName/Views/Shared. If it can't find one there, it will fall back on looking in /Views/Stats or /Views/Shared.

Routing and URL Generation with Areas

At runtime, how does ASP.NET MVC find out what areas exist, and how does it integrate them into the host application?When your application starts up, it runs a method in Global.asax.cs called Application_Start(). By default, this method contains the following.

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();
    RegisterRoutes(RouteTable.Routes);
}

The line shown in bold tells the framework to scan your referenced assemblies for all area registration classes. An area registration class is any public class inherited from AreaRegistration. You'll recall from Figure 8-2 that when Visual Studio creates the files for a new area, it creates one called AdminAreaRegistration.cs. For an area called Admin, that class will contain the following by default.

namespace MyAppName.Areas.Admin
{
    public class AdminAreaRegistration : AreaRegistration
    {
        public override string AreaName { get { return "Admin"; } }

        public override void RegisterArea(AreaRegistrationContext context)
        {
            context.MapRoute(
                "Admin_default",
                "Admin/{controller}/{action}/{id}",
                new { action = "Index", id = UrlParameter.Optional }
            );
        }
    }
}

As you can guess, each area registration class declares the name and routing entries for a particular area. The MVC Framework finds each such class, constructs an instance using its default (parameterless) constructor, and calls its RegisterArea() method. This is where you can add any initialization logic for your area, such as registering route entries.

Warning

Notice that the default area route doesn't include a default value for controller. This means it would match the URL /Admin/Home, but not simply /Admin. To fix this, you can change the entry's defaults to new { controller = "Home", action = "Index", id = UrlParameter.Optional } or something similar. But before you add a HomeController to your new area—or any other controller with the same name as a controller in your top-level /Controllers folder—be sure to read the section "Areas and the Ambiguous Controller Problem" later in this chapter.

Linking to an Action in the Same Area

When you use Url.Action(), Html.ActionLink(), or any other method that internally relies on the framework's URL generation feature, it will detect if the current request is being handled by a route entry from a specific area, and if so, will only match route entries from the same area. The point of this is that when working in a specific area, you can place links without thinking about areas—the assumption is that you intend to stay on the same area.

For example, if you had an area called Admin and were using the default routing configuration, then Url.Action("Export", "Stats") would produce

  • /Admin/Stats/Export if the current request was in the Admin area. This URL would only match a controller called StatsController in the Admin area's namespace.

  • /Stats/Export if the current request was not associated with any area. This URL would match any controller called StatsController anywhere in your application (i.e., in the root area or any child area).

As explained in the preceding sidebar, it works by filtering RouteTable.Routes so that it only considers matching against route entries associated with the current area.

Linking to an Action in a Different Area

To link across areas, you need to specify an extra routing parameter called area. For example, from any view you could generate a link tag as follows.

<%: Html.ActionLink("Export...", "Export", "Stats", new { area = "Admin" }, null) %>

No matter which area (if any) the current request was associated with, under the default routing configuration this would produce the following markup:

<a href="/Admin/Stats/Export">Export...</a>

Again, the outbound URL generation works by filtering RouteTable.Routes so that it only considers entries associated with the Admin area. When the user clicks the link and requests /Admin/Stats/Export, the request will only match controllers in the Admin area's namespace.

Note

Html.ActionLink()'s final parameter (htmlAttributes, for which I've passed null) is required when placing cross-area links. If you omit this, the runtime will think you're trying to call a different method overload that doesn't even take a routeValues parameter. If you dislike this inelegance, you could wrap the call inside your own helper method (perhaps called Html.AreaActionLink()—you'll see how to create a custom HTML helper in Chapter 11) that takes a more convenient set of parameters.

Linking to an Action in the Root Area

If you're on a particular area and want to jump up to an action outside all areas (which is also known as the root area), then you can pass either an empty string or null for the area value when constructing a URL. It's slightly easier to use an empty string, because if you use null, you'll usually have to write it as (string)null; otherwise, the compiler won't know how to interpret this in the context of an anonymously typed object.

Areas and Explicitly Named Routes

If you give names to any route entries, then those names must be unique across your whole application. You can't use the same route name in two different areas.

If you have a named route entry in any area (including the root area), then you can always reference it explicitly. For example, if you call <%:Html.RouteLink("Click me", "routeName") %>, then you'll get a link to the nominated route entry regardless of what area the current request is associated with.

Areas and the Ambiguous Controller Problem

Normally, it doesn't matter if you use the same controller name in multiple areas. That's pretty much the whole point of areas! Unfortunately there is one exception, and that's to do with the root area.

When an incoming request matches a route entry associated with the root area (i.e., a regular top-level entry configured in Global.asax.cs), that route entry won't be associated with any particular area namespaceby default. So, when the controller factory goes looking for a matching controller, it will consider controllers in any namespace—in all areas and outside them all. If you have a HomeController in /Controllers and another one in /Areas/anyName/Controllers, then if you request the URL /Home, the framework won't know which controller to use, so it will fail, saying "Multiple types were found that match the controller name 'Home'."

To resolve this, you need to alter your root area routing configuration and specify your root area controller namespace explicitly. For example, you could alter the default route entry in Global.asax.cs as follows:

routes.MapRoute(
    "Default",                                    // Route name
    "{controller}/{action}/{id}",                 // URL with parameters
    new { controller = "Home", action = "Index",  // Parameter defaults
          id = UrlParameter.Optional },
    new [] { "MyAppName.Controllers" }           // Prioritized namespace
);

Now, when somebody requests a URL such as /Home or simply /, it will match this route entry and use the controller normally located at /Controllers/HomeController.cs. And if someone requests /Admin/Home, it will use the controller normally located at /Areas/Admin/Controllers/HomeController.cs. This is the behavior that most ASP.NET MVC developers will expect and want.

Areas Summary

You now know how to create multiple areas in a single Visual Studio project, populate them with controllers and views, configure their routing entries, and place links within them and across them. This is the most common way to use areas and has proven to be an effective way to structure large ASP.NET MVC applications.

Unit Testing Your Routes

Routing isn't always easy to configure, but it's critical to get right. As soon as you have more than a few custom RouteTable.Routes entries and then change or add one, you could unintentionally break another. There are two main possible automated testing strategies for routing configurations:

  • Implicitly via UI automation tests: A set of UI automation tests (also called integration tests) can give you confidence that your entire technology stack satisfies your specifications. This naturally includes your routing configuration, because the tests will involve requesting different actions, and if certain actions cease to be reachable because they have no URL, those tests will fail.

  • Explicitly via unit tests: If you want to think of your routing configuration as a separately specified component with precisely defined inputs and outputs, you can design its behavior in isolation using unit tests. This has the advantage that you can almost instantly confirm that each routing configuration change has the desired effect and no unwanted side effects, but this might be unnecessary if you don't change your routing configuration very often and are also doing UI automation tests.

Unit testing a routing configuration is pretty easy, because the routing system has a very constrained range of possible inputs and outputs. Let's see how you can do it, comparing the approaches ofusing mocks and test doubles, and also build some utility methods that can make your routing unit test code very simple.

Testing Inbound URL Routing

Remember that you can access your routing configuration via a public static method in Global.asax.cs called RegisterRoutes(). So, a basic route test looks like the following:

Note

If you're unsure how to get started with unit testing, including what tools you need to download or how to add a test project to your solution, refer back to the "TDD: Getting Started" sidebar in Chapter 4. If, on the other hand, you're already very familiar with unit testing and mocking, then the following discussion may seem a bit basic—you might prefer just to skim the code.

[TestFixture]
public class InboundRouteMatching
{
    [Test]
    public void TestSomeRoute()
    {
        // Arrange (obtain routing config + set up test context)
        RouteCollection routeConfig = new RouteCollection();
        MvcApplication.RegisterRoutes(routeConfig);
        HttpContextBase testContext = Need to get an instance somehow

        // Act (run the routing engine against this HttpContextBase)
        RouteData routeData = routeConfig.GetRouteData(testContext);

        // Assert
        Assert.IsNotNull(routeData, "NULL RouteData was returned");
        Assert.IsNotNull(routeData.Route, "No route was matched");
        // Add other assertions to test that this is the right RouteData
    }
}

The tricky part is obtaining an HttpContextBase instance. Of course, you don't want to couple your test code to any real web server context (so you're not going to use System.Web.HttpContext). The idea is to set up a special test instance of HttpContextBase. You could create a test double or a mock—let's examine both techniques.

Using Test Doubles

The first way to obtain an HttpContextBase instance is to write your own test double. Essentially, this means deriving a class from HttpContextBase and supplying test implementations of only the methods and properties that will actually get used.

Here's a minimal test double that's enough to test inbound and outbound routing. It tries to do as little as possible. It only implements the methods that routing will actually call (you discover which ones by trial and error), and even then, those implementations are little more than stubs.

public class TestHttpContext : HttpContextBase
{
    TestHttpRequest testRequest;
    TestHttpResponse testResponse;
    public override HttpRequestBase Request { get { return testRequest; } }
    public override HttpResponseBase Response { get { return testResponse; } }
    public TestHttpContext(string url)
    {
        testRequest = new TestHttpRequest() {
            _AppRelativeCurrentExecutionFilePath = url
        };
        testResponse = new TestHttpResponse();
    }

    class TestHttpRequest : HttpRequestBase
    {
        public string _AppRelativeCurrentExecutionFilePath { get; set; }
        public override string AppRelativeCurrentExecutionFilePath
        {
            get { return _AppRelativeCurrentExecutionFilePath; }
        }

        public override string ApplicationPath { get { return null; } }
        public override string PathInfo { get { return null; } }
        public override NameValueCollection ServerVariables {
            get { return null; }
        }
    }
    class TestHttpResponse : HttpResponseBase
    {
        public override string ApplyAppPathModifier(string x) { return x; }
    }
}

Now, using your test double, you can write a complete test:

[Test]
public void ForwardSlashGoesToHomeIndex()
{
    // Arrange (obtain routing config + set up test context)
    RouteCollection routeConfig = new RouteCollection();
    MvcApplication.RegisterRoutes(routeConfig);
HttpContextBase testContext = new TestHttpContext("~/");

    // Act (run the routing engine against this HttpContextBase)
    RouteData routeData = routeConfig.GetRouteData(testContext);

    // Assert
    Assert.IsNotNull(routeData, "NULL RouteData was returned");
    Assert.IsNotNull(routeData.Route, "No route was matched");
    Assert.AreEqual("Home", routeData.Values["controller"], "Wrong controller");
    Assert.AreEqual("Index", routeData.Values["action"], "Wrong action");
}

After recompiling your tests project, you can run it. Launch NUnit GUI from your start menu or from Program FilesNUnit versionin et-2.0 unit.exe, choose File

Using Test Doubles

Note

If you're using .NET 4, you must use NUnit 2.5.5 or later. Otherwise, NUnit GUI will be unable to load your assembly, and will report an error saying "System.BadImageFormatException" or similar.

NUnit will scan the assembly to find the [TestFixture] classes, and will display a hierarchy of test fixtures and tests, as shown in Figure 8-4. Once you click Run, you should see a green light—the test has passed! This proves that the URL / is handled by the Index action on HomeController.

NUnit GUI displaying a successful test run

Figure 8-4. NUnit GUI displaying a successful test run

Using a Mocking Framework (Moq)

The other main way to get an HttpContextBase object is by using a mocking framework. The mocking framework lets you programmatically build a mock object on the fly. The mock object is just like a test double, except that you generate it dynamically at runtime rather than explicitly writing it out as a regular class. To use a mocking framework, all you have to do is tell it which interface or abstract base class you want satisfied, and specify how the mock object should respond when selected members are called.

Moq is an easy-to-use, open source mocking framework. If you followed the setup instructions in Chapter 4, you should already have a reference to its assembly. Now, assuming you've imported the Moq namespace (by writing using Moq;), you can write a unit test like this:

[Test]
public void ForwardSlashGoesToHomeIndex()
{
    // Arrange (obtain routing config + set up test context)
    RouteCollection routeConfig = new RouteCollection();
    MvcApplication.RegisterRoutes(routeConfig);
    var mockHttpContext = MakeMockHttpContext("~/");

    // Act (run the routing engine against this HttpContextBase)
    RouteData routeData = routeConfig.GetRouteData(mockHttpContext.Object);

    // Assert
    Assert.IsNotNull(routeData, "NULL RouteData was returned");
    Assert.IsNotNull(routeData.Route, "No route was matched");
    Assert.AreEqual("Home", routeData.Values["controller"], "Wrong controller");
    Assert.AreEqual("Index", routeData.Values["action"], "Wrong action");
}

You can implement the MakeMockHttpContext() method as follows:

private static Mock<HttpContextBase> MakeMockHttpContext(string url)
{
    var mockHttpContext = new Mock<HttpContextBase>();

    // Mock the request
    var mockRequest = new Mock<HttpRequestBase>();
    mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object);
    mockRequest.Setup(x => x.AppRelativeCurrentExecutionFilePath).Returns(url);

    // Mock the response
    var mockResponse = new Mock<HttpResponseBase>();
    mockHttpContext.Setup(x => x.Response).Returns(mockResponse.Object);
    mockResponse.Setup(x => x.ApplyAppPathModifier(It.IsAny<string>()))
                .Returns<string>(x => x);

    return mockHttpContext;
}

Considering that you didn't have to write a test double for HttpContextBase, HttpRequestBase, or HttpResponseBase, this is less code than before. Of course, it can be streamlined further, by keeping only the test-specific code in each [Test] method:

[Test]
public void ForwardSlashGoesToHomeIndex()
{
    TestRoute("~/", new { controller = "Home", action = "Index" });
}

andall the boilerplate code in a separate method:

public RouteData TestRoute(string url, object expectedValues)
{
    // Arrange (obtain routing config + set up test context)
    RouteCollection routeConfig = new RouteCollection();
    MvcApplication.RegisterRoutes(routeConfig);
    var mockHttpContext = MakeMockHttpContext(url);

    // Act (run the routing engine against this HttpContextBase)
    RouteData routeData = routeConfig.GetRouteData(mockHttpContext.Object);

    // Assert
    Assert.IsNotNull(routeData.Route, "No route was matched");
    var expectedDict = new RouteValueDictionary(expectedValues);
    foreach (var expectedVal in expectedDict)
    {
        if (expectedVal.Value == null)
            Assert.IsNull(routeData.Values[expectedVal.Key]);
        else
            Assert.AreEqual(expectedVal.Value.ToString(),
                            routeData.Values[expectedVal.Key].ToString());
    }

    return routeData; // ... in case the caller wants to add any other assertions
}

Note

Notice that when TestRoute() compares expected route values against actual ones (during the assert phase), it converts everything to strings by calling .ToString(). Obviously, URLs can only contain strings (not ints or anything else), but expectedValues might contain an int (e.g., { page = 2 }). It's only meaningful to compare the string representations of each value.

Now you can add a [Test] method for a specimen of every form of inbound URL with barely a smidgen of repeated code—for example:

[Test]
public void ProductDeletionGoesToProductsDeleteWithId()
{
    TestRoute("~/Products/Delete/58",
              new { controller = "Products", action = "Delete", id = 58 });
}

You're not limited to testing for just controller, action, and id—this code works equally well for any of your custom routing parameters.

Testing Outbound URL Generation

It's equally possible to test how the framework generates outbound URLs from your configuration. You might want to do this if you consider your public URL schema to be a contract that must not be changed except deliberately.

This is slightly different from testing inbound route matching. Just because a particular URL gets mapped to a certain set of RouteData values, it doesn't mean that same set of RouteData values will be mapped back to the that same URL (there could be multiple matching route entries). Having a solid set of tests for both inbound and outbound routing can be invaluable if you're creating a complex routing configuration and find yourself changing it frequently.

You can use the same test double from before:

[Test]
public void EditProduct50_IsAt_Products_Edit_50()
{
    string result = GenerateUrlViaTestDouble(
        new { controller = "Products", action = "Edit", id = 50 }
    );

    Assert.AreEqual("/Products/Edit/50", result);
}

private string GenerateUrlViaTestDouble(object values)
{
    // Arrange (get the routing config and test context)
    RouteCollection routeConfig = new RouteCollection();
    MvcApplication.RegisterRoutes(routeConfig);
    var testContext = new TestHttpContext(null);
    RequestContext context = new RequestContext(testContext, new RouteData());

    // Act (generate a URL)
    return UrlHelper.GenerateUrl(null, null, null, /* Explained below */
        new RouteValueDictionary(values), routeConfig, context, true);
}

The reason for all the null parameters in the call to UrlHelper.GenerateUrl() is that, instead of explicitly passing a routeName, a controller, or an action, it's easier to let the framework take its values from the RouteValueDictionary parameter.

Alternatively, you can choose not to bother with the HttpContextBase test double, and instead create a mock implementation on the fly. Simply replace GenerateUrlViaTestDouble() with GenerateUrlViaMocks():

private string GenerateUrlViaMocks(object values)
{
    // Arrange (get the routing config and test context)
    RouteCollection routeConfig = new RouteCollection();
    MvcApplication.RegisterRoutes(routeConfig);
    var mockContext = MakeMockHttpContext(null);
    RequestContext context = new RequestContext(mockContext.Object,new RouteData());

    // Act (generate a URL)
    return UrlHelper.GenerateUrl(null, null, null,
        new RouteValueDictionary(values), routeConfig, context, true);
}

Note that MakeMockHttpContext() was defined in the previous mocking example.

Unit Testing Area Routes

Areas introduce a slight complication to unit testing your routing configuration. Area route entries aren't all contained in Global.asax.cs's RegisterRoutes() method—they're spread out over all your AreaRegistration classes.

To involve those extra area-specific route entries in your unit tests, you could update either the GenerateUrlViaTestDouble() or the GenerateUrlViaMocks() method as follows:

// Arrange (get the routing config and test context)
RouteCollection routeConfig = new RouteCollection();
RegisterAllAreas(routeConfig);
MvcApplication.RegisterRoutes(routeConfig);
// ... rest as before ...

The RegisterAllAreas() method needs to instantiate all your AreaRegistration classes and call their RegisterArea() methods. Here's how it could work:

private static void RegisterAllAreas(RouteCollection routes)
{
    var allAreas = new AreaRegistration[] {
        new AdminAreaRegistration(),
        new BlogAreaRegistration(),
        // ...etc. (Manually add whichever ones you're using)
    };

    foreach (AreaRegistration area in allAreas) {
        var context = new AreaRegistrationContext(area.AreaName, routes);
        context.Namespaces.Add(area.GetType().Namespace);
        area.RegisterArea(context);
    }
}

Manually building an array of all your application's AreaRegistration classes might feel inconvenient, but it won't be difficult to maintain, and it's much easier than trying to replicate the MVC Framework's ability to detect them all automatically. And now you can specify an area parameter in any outbound URL generation unit test.

[Test]
public void AdminAreaStatsExport_IsAt_Admin_Stats_Export()
{
    string result = GenerateUrlViaMocks(
        new { area = "Admin", controller = "Stats", action = "Export" }
    );

    Assert.AreEqual("/Admin/Stats/Export", result);
}

Further Customization

You've now seen the majority of what core routing is expected to do, and how to make use of it in your ASP.NET MVC application. Let's now consider a few extensibility points that give you additional powers in advanced use cases.

Implementing a Custom RouteBase Entry

If you don't like the way that standard Route objects match URLs, or want to implement something unusual, you can derive an alternative class directly from RouteBase. This gives you absolute control over URL matching, parameter extraction, and outbound URL generation. You'll need to supply implementations for two methods:

  • GetRouteData(HttpContextBase httpContext): This is the mechanism by which inbound URL matching works—the framework calls this method on each RouteTable.Routes entry in turn, until one of them returns a non-null value. If you want your custom route entry to match the given httpContext (e.g., after inspecting httpContext.Request.Path), then return a RouteData structure describing your chosen IRouteHandler (usually MvcRouteHandler) and any parameters you've extracted. Otherwise, return null.

  • GetVirtualPath(RequestContext requestContext, RouteValueDictionary values): This is the mechanism by which outbound URL generation works—the framework calls this method on each RouteTable.Routes entry in turn, until one of them returns a non-null value. If you want to supply a URL for a given requestContext/values pair, return a VirtualPathData object that describes the computed URL relative to your virtual directory root. Otherwise, return null.

Of course, you can mix custom RouteBase objects with normal Route objects in the same routing configuration. For example, if you're replacing an old web site with a new one, you might have a disorganized collection of old URLs that you want to retain support for on the new site (to avoid breaking incoming links). Instead of setting up a complex routing configuration that recognizes a range of legacy URL patterns, you might create a single custom RouteBase entry that recognizes specific legacy URLs and passes them on to some controller that can deal with them:

using System.Linq;

public class LegacyUrlsRoute : RouteBase
{
    // In practice, you might fetch these from a database
    // and cache them in memory
    private static string[] legacyUrls = new string[] {
        "~/articles/may/zebra-danio-health-tips.html",
        "~/articles/VelociraptorCalendar.pdf",
        "~/guides/tim.smith/BuildYourOwnPC_final.asp"
    };

    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
        string url = httpContext.Request.AppRelativeCurrentExecutionFilePath;
        if(legacyUrls.Contains(url, StringComparer.OrdinalIgnoreCase)) {
            RouteData rd = new RouteData(this, new MvcRouteHandler());
            rd.Values.Add("controller", "LegacyContent");
            rd.Values.Add("action", "HandleLegacyUrl");
            rd.Values.Add("url", url);
            return rd;
        }
        else
            return null; // Not a legacy URL
    }
public override VirtualPathData GetVirtualPath(RequestContext requestContext,
                                                   RouteValueDictionary values)
    {
        // This route entry never generates outbound URLs
        return null;
    }
}

Register this at the top of your routing configuration (so it takes priority over other entries):

public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
    routes.Add(new LegacyUrlsRoute());
    // ... other route entries go here
}

and you'll now find that any of those legacy URLs get handled by a HandleLegacyUrl() action method on LegacyContentController (assuming that it exists). All other URLs will match against the rest of your routing configuration as usual.

Implementing a Custom Route Handler

All the routing examples so far have used MvcRouteHandler for their RouteHandler property. In most cases, that's exactly what you want—it's the MVC Framework's default route handler, and it knows how to find and invoke your controller classes.

Even so, the routing system lets you use your own custom IRouteHandler if you wish. You can use custom route handlers on individual routes, or on any combination of routes. Supplying a custom route handler lets you take control of the request processing at a very early stage: immediately after routing and before any part of the MVC Framework kicks in. You can then replace the remainder of the request processing pipeline with something different.

Here's a very simple IRouteHandler that writes directly to the response stream:

public class HelloWorldHandler : IRouteHandler
{
    public IHttpHandler GetHttpHandler(RequestContext requestContext)
    {
        return new HelloWorldHttpHandler();
    }

    private class HelloWorldHttpHandler : IHttpHandler
    {
        public bool IsReusable { get { return false; } }

        public void ProcessRequest(HttpContext context)
        {
            context.Response.Write("Hello, world!");
        }
    }
}

You can register it in the route table like this:

routes.Add(new Route("SayHello", new HelloWorldHandler()));

and then invoke it by browsing to /SayHello (see Figure 8-5).

Output from the custom IRouteHandler

Figure 8-5. Output from the custom IRouteHandler

There's no concept of controllers or actions here, because you've bypassed everything in the MVC Framework after routing. You could invent a completely independent web application platform and attach it to the core routing system to take advantage of its placeholder, defaults, route validation, and URL generation features.

In Chapter 18, you'll see how to use a .NET 4 route handler type called PageRouteHandler, which knows how to locate and invoke ASP.NET Web Forms pages. That lets you integrate ASP.NET Web Forms into the routing system.

URL Schema Best Practices

With so much control over your URL schema, you may be left wondering where to start. What constitutes good URL design? When and how does it make a difference anyway?

Since the Web 2.0 boom a few years back, many people have started to take URL design seriously. A few important principles have emerged, and if you abide by them, they will help to improve the usability, interoperability, and search engine rankings of your site.

Make Your URLs Clean and Human-Friendly

Please remember that URLs are just as much part of your UI as the fonts and graphics you choose. End users certainly notice the contents of their browser's address bar, and they will feel more comfortable bookmarking and sharing URLs if they can understand them. Consider this URL:

http://www.amazon.com/gp/product/1430210079/ref=r2_dwew_cc_e22_d3?pf_rd_m=
LCIEMCJSLEMCJ&pf_rd_s=center-2&pf_rd_r=3984KEDMDJEMDKEMDKE&pf_rd_t=103
&pf_rd_p=489302938&pf_rd_i=493855

Now, do you want to share that link with your mother? Is it safe for work? Does it contain your private account information? (I actually changed the random-looking query string values because I don't know.) Can you read it out over the phone? Is it a permanent URL, or will it change over time? I'm sure all those query string parameters are being used for something, but their damage on the site's usability is quite severe. The same page could be reachable via:

http://www.amazon.com/books/pro-aspnet-mvc-framework

The following list gives some guidelines on how to make your URLs human-friendly:

  • Design URLs to describe their content, not the implementation details of your application. Use /Articles/AnnualReport rather than /Website_v2/CachedContentServer/FromCache/AnnualReport.

  • Prefer content titlesover ID numbers. Use /Articles/AnnualReport rather than /Articles/2392. If you must use an ID number (to distinguish items with identical titles, or to avoid the extra database query needed to find an item by its title), then use both (e.g., /Articles/2392/AnnualReport). It takes longer to type, but it makes more sense to a human and improves search engine rankings. Your application can just ignore the title and display the item matching that ID.

  • If possible, don't use file name extensions for HTML pages (e.g., .aspx or .mvc),[54] but do use them for specialized file types (e.g., .jpg, .pdf, .zip). Web browsers don't care about file name extensions if you set the MIME type appropriately, but humans still expect PDF files to end with .pdf.

  • Where relevant, create a sense of hierarchy (e.g., /Products/Menswear/Shirts/Red) so your visitor can guess the parent category's URL.

  • Be case insensitive (someone might want to type in the URL from a printed page). The ASP.NET routing system is case insensitive by default.

  • Avoid technical-looking symbols, codes, and character sequences. If you want a word separator, use a dash[55] (e.g., /my-great-article). Underscores are unfriendly, and URL-encoded spaces are bizarre (as in /my+great+article) or disgusting (as in /my%20great%20article).

  • Don't change URLs. Broken links equal lost business. When you do change URLs, continue to support the old URL schema for as long as possible via permanent (301) redirections.

URLs should be short, easy to type, hackable (human-editable), and persistent, and should visualize site structure. Jakob Nielsen, usability guru, expands on this topic at www.useit.com/alertbox/990321.html. Tim Berners-Lee, inventor of the Web, offers similar advice (see www.w3.org/Provider/Style/URI).

Follow HTTP Conventions

The Web has a long history of permissiveness. Even the most mangled HTML is rendered to the best of the browser's abilities, and HTTP can be abused without apparent consequence. But as you will see, standards-compliant web applications are more reliable, more usable, and can make more money.

GET and POST: Pick the Right One

The rule of thumb is that GET requests should be used for all read-only information retrieval, while POST requests should be used for any write operation that changes state on the server. In standards-compliance terms, GET requests are for safe interactions (having no side effects besides information retrieval), and POST requests are for unsafe interactions (making a decision or changing something). These conventions are set out by the W3C standards consortium at www.w3.org/Provider/Style/URI.

GET requests are addressable: all the information is contained in the URL, so it's possible to bookmark and link to these addresses. Traditional ASP.NET Web Forms inappropriately uses POST requests for navigation through server controls, making it impossible to bookmark or link to, say, page 2 of a GridView display. You can do better with ASP.NET MVC.

Don't use (and to be strict, don't allow) GET requests for operations that change state. Many web developers learned the hard way in 2005, when Google Web Accelerator was released to the public. This application prefetches all the content linked from each page, which is legal because GET requests should be safe. Unfortunately, many web developers had ignored the HTTP conventions and placed simple links to "delete item" or "add to shopping cart" in their applications. Chaos ensued.

One company believed their content management system was the target of repeated hostile attacks, because all their content kept getting deleted. They later discovered that a search engine crawler had hit upon the URL of an administrative page and was crawling all the "delete" links. Authentication might protect you from this, but it wouldn't protect you from web accelerators.

On Query Strings

It's not always bad to use query string arguments in a URL, but it's often better to avoid them. The first problem is with their syntax: all those question marks and ampersands violate basic usability principles. They're just not human-friendly. Secondly, query string name/value pairs can usually be rearranged for no good reason (/resource?a=1&b=2 usually gives the same result as /resource?b=2&a=1). Technically, the ordering can be significant, so anyone indexing these URLs has to treat them as different. This can lead to noncanonicalization problems and thus a loss of search engine ranking (discussed shortly).

Despite persistent myths, modern search engines do index URLs involving query string parameters. Still, it's possible that keywords appearing in the query string part of a URL will be treated as less significant.

So, when should you use query string arguments? Nobody is an authority on the subject, but I would use them as follows:

  • To save time in cases where I'm not interested in human-readability or SEO, and wouldn't expect someone to bookmark or link to the page. This might include the Your Cart screen in SportsStore, and perhaps all internal, administrator-only pages (for these, {controller}/{action}?params may be good enough).

  • To create the impression of putting values into an algorithm rather than retrieving an existing resource (e.g., when searching /search?query=football or paging /articles/list?page=2). For these URLs, I might be less interested in SEO or helping people who want to type in the URLs by hand (e.g., from a printed page).

This is subjective, and it's up to you to decide on your own guidelines.

Use the Correct Type of HTTP Redirection

There are two main types of HTTP redirection commands, as described in Table 8-7. Both cause the browser to navigate to the new URL via a GET request, so most developers don't pay attention to the difference.

Table 8-7. The Most Common Types of HTTP Redirection

Status Code

Meaning

Search Engine Treatment

Correct Usage

* That is, unless you redirect to a different hostname. If you do that, the search engine may become suspicious that you're trying to hijack someone else's content, and may index it under the destination URL instead.

301

Moved permanently (implies that the URL is forever obsolete and should never be requested again, and that any inbound links should be updated to the new URL)

Indexes the content under the new URL; migrates any references or page ranking from the old URL

When you're changing URL schema (e.g., from old-style ASP.NET URLs) or ensuring that each resource has a single, canonical URL

302

Moved temporarily (instructs the client to use the supplied replacement URL for this request only, but next time to try the old URL again)

Keeps indexing the content under the old URL*

For routine navigation between unrelated URLs

ASP.NET MVC uses a 302 whenever you return a RedirectToRouteResult or a RedirectResult. It's not an excuse to be lazy: if you mean 301, send a 301. You could make a custom action result, perhaps constructed via an extension method on a normal RedirectToRouteResult:

public static class PermanentRedirectionExtensions
{
    public static PermanentRedirectToRouteResult AsMovedPermanently
        (this RedirectToRouteResult redirection)
    {
        return new PermanentRedirectToRouteResult(redirection);
    }

    public class PermanentRedirectToRouteResult : ActionResult
    {
        public RedirectToRouteResult Redirection { get; private set; }
        public PermanentRedirectToRouteResult(RedirectToRouteResult redirection)
        {
            this.Redirection = redirection;
        }
        public override void ExecuteResult(ControllerContext context)
        {
            // After setting up a normal redirection, switch it to a 301
            Redirection.ExecuteResult(context);
            context.HttpContext.Response.StatusCode = 301;
        }
    }
}

Whenever you've imported this class's namespace, you can simply add .AsMovedPermanently() to the end of any redirection:

public ActionResult MyActionMethod()
{
    return RedirectToAction("AnotherAction").AsMovedPermanently();
}

SEO

You've just considered URL design in terms of maximizing usability and compliance with HTTP conventions. Let's now consider specifically how URL design is likely to affect search engine rankings.

Here are some techniques that can improve your chances of being ranked highly:

  • Use relevant keywords in your URLs: /products/dvd/simpsons will score more points than /products/293484.

  • As discussed, minimize your use of query string parameters and don't use underscores as word separators. Both can have adverse effects on search engine placement.

  • Give each piece of content one single URL: its canonical URL. Google rankings are largely determined by the number of inbound links reaching a single index entry, so if you allow the same content to be indexed under multiple URLs, you risk spreading out the weight of incoming links between them. It's far better to have a single high-ranking index entry than several low-ranking ones.

  • If you need to display the same content on multiple URLs (e.g., to avoid breaking old links), then redirect visitors from the old URLs to the current canonical URL via an HTTP 301 (moved permanently) redirect.

  • Obviously, your content has to be addressable, otherwise it can't be indexed at all. That means it must be reachable via a GET request, not depending on a POST request or any sort of JavaScript-, Flash-, or Silverlight-powered navigation.

SEO is a dark and mysterious art, because Google and the other search engines will never reveal the inner details of their ranking algorithms. URL design is only part of it—link placement and getting inbound links from other popular sites are more critical. Focus on making your URLs work well for humans, and those URLs will tend to do well with search engines, too.

Summary

You've now had a close look at the routing system—how to use it and how it works internally. This means you can now implement almost any URL schema, producing human-friendly and search engine-optimized URLs, without having to hard-code a URL anywhere in your application.

Over the next two chapters, you'll explore the heart of the MVC Framework itself, gaining advanced knowledge of controllers and actions.



[47] Its fully qualified name is System.Web.Routing.RouteTable.Routes.

[48] MvcRouteHandler knows how to find controller classes and invoke them (actually, it delegates that task to an HTTP handler called MvcHandler, which asks your registered controller factory to instantiate a certain controller by name). You'll learn more about controller factories in Chapter 10.

[49] Normally, when you ask for Request.Path, ASP.NET will give you a URL with a leading slash (e.g., /Catalog). For routing URL patterns, the leading slash is implicit (in other words, don't put a leading slash into your routing URL patterns—just put Catalog).

[50] When you use the MapRoute() extension method to register route entries, it takes an object parameter called constraints. Behind the scenes, it converts that to a RouteValueDictionary automatically.

[51] This doesn't mean the request will be rejected altogether; it just means it won't be intercepted by the routing system. Responsibility for handling the request will then pass back to IIS, which may or may not produce a response, depending on whether there's another registered handler for that URL.

[52] In case you're wondering, the last parameter (for which I've passed null) optionally lets you specify additional HTML attributes that would be rendered on the HTML tag.

[53] Downloadable from http://codeplex.com/aspnet; make sure you get the ASP.NET MVC 2 version.

[54] To avoid using file name extensions for ASP.NET MVC-generated pages, you need to be running IIS 7 in integrated pipeline mode, or IIS 6 with .NET 4 or a wildcard map. See Chapter 16 for details.

[55] For more about dashes and underscores, see www.mattcutts.com/blog/dashes-vs-underscores/.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.189.189.63