Share via



January 2009

Volume 24 Number 01

Extreme ASP.NET - Routing with ASP.NET Web Forms

By Scott Allen | January 2009

Code download available

Contents

What Is Routing?
A Brief History of URL Rewriting
Of Routes and Route Handlers
Configuring ASP.NET for Routing
Configuring Routes
The Recipe Routing Handler
Routing and Security
URL Generation
Wrapping Up with Routes

Service Pack 1 for the Microsoft .NET Framework 3.5 introduced a routing engine to the ASP.NET runtime. The routing engine can decouple the URL in an incoming HTTP request from the physical Web Form that responds to the request, allowing you to build friendly URLs for your Web applications. Although you've been able to use friendly URLs in previous versions of ASP.NET, the routing engine provides an easier, cleaner, and more testable approach.

The routing engine began as a part of the ASP.NET Model View Controller (MVC) framework, which is in a preview stage as of this writing. However, Microsoft packaged the routing logic into the System.Web.Routing assembly and released the assembly with SP1. The assembly currently provides routing for Web sites using ASP.NET Dynamic Data features (which were also released with SP1), but in this column I will demonstrate how to use the routing functionality with ASP.NET Web Forms.

What Is Routing?

Imagine you have an ASP.NET Web Form named RecipeDisplay.aspx, and this form lives inside a folder named Web Forms. The classic approach to viewing a recipe with this Web Form is to build a URL pointing to the physical location of the form and encode some data into the query string to tell the Web Form which recipe to display. The end of such a URL might look like the following: /WebForms/RecipeDisplay.aspx?id=5, where the number 5 represents a primary key value in a database table full of recipes.

Routing is fundamentally about decomposing a URL endpoint into parameters and then using those parameters to steer the HTTP request processing to a specific component. Let's take the URL /recipe/5 as an example. With the proper routing configuration, you can still respond to this URL with the Web Form RecipeDisplay.aspx.

The URL no longer represents a physical path. Instead, the word recipe represents a parameter that the routing engine can use to locate a component for processing recipe requests. The number 5 represents a second parameter that you'll need during processing to display a specific recipe. Instead of encoding database keys into the URL, a better idea might be to use a URL like /recipe/tacos. This URL not only includes enough parameters to display a specific recipe, but is also human readable, reveals its intent to end users, and includes important keywords for search engines to see.

A Brief History of URL Rewriting

In ASP.NET, using a URL ending with /recipe/tacos traditionally required one to work with a URL rewriting scheme. For detailed information on URL rewriting, see Scott Mitchell's definitive article " URL Rewriting in ASP.NET." The article describes the common implementation of URL rewriting in ASP.NET using an HTTP module and the static RewritePath method of the HttpContext class. Scott's article also details the benefits of friendly, hackable URLs.

Those of you who have used the RewritePath API in the past are probably familiar with some of the quirks and weaknesses in the rewriting approach. The primary problem with RewritePath is how the method changes the virtual path used during the processing of a request. With URL rewriting, you needed to fix up the postback destination of each Web Form (often by rewriting the URL a second time during the request) to avoid postbacks to the internal, rewritten URL.

In addition, most developers would implement URL rewriting as a one-way translation because there was no easy mechanism to let the URL rewriting logic work in two directions. For example, it was easy to give the URL rewriting logic a public-facing URL and have the logic return the internal URL of a Web Form. It was difficult to give the rewriting logic the internal URL of a Web Form and have it return the public URL required to reach the form. The latter is useful when generating hyperlinks to other Web Forms that hide behind rewritten URLs. As you'll see in the rest of this column, the URL routing engine circumvents these problems.

fig01.gif

Figure 1 Routes, Route Handlers, and the Routing Module

Of Routes and Route Handlers

There are three fundamental players in the URL routing engine: routes, route handlers, and the routing module. A route associates a URL with a route handler. An instance of the Route class from the System.Web.Routing namespace represents a route during run time and describes the route's parameters and constraints. A route handler inherits from the System.Web.Routing.IRouteHandler interface. This interface requires the route handler to implement a GetHttpHandler method that returns an object implementing the IHttpHandler interface. The IHttpHandler interface has been a part of ASP.NET since the beginning, and a Web Form (a System.Web.UI.Page) is an IHttpHandler. When using routing with Web Forms, your route handlers need to locate, instantiate, and return the proper Web Form. Finally, the routing module plugs into the ASP.NET processing pipeline. The module will intercept incoming requests, examine the URL, and discover if there are any matching routes defined. The module will retrieve the associated route handler for a matching route and ask the routing handler for the IHttpHandler that will process the request.

The three primary types I've mentioned are shown in Figure 1. In the next section, I'll put these three players to work.

Configuring ASP.NET for Routing

To configure an ASP.NET Web site or Web application for routing, you first need to add a reference to the System.Web.Routing assembly. The SP1 installation for the .NET Framework 3.5 will install this assembly into the global assembly cache, and you can find the assembly inside the standard "Add Reference" dialog box.

You'll also need to configure the routing module into the ASP.NET pipeline. The routing module is a standard HTTP module. For IIS 6.0 and earlier and for the Visual Studio Web development server, you install the module using the <httpModules> section of web.config, as you see here:

<httpModules> <add name="RoutingModule" type="System.Web.Routing.UrlRoutingModule, System.Web.Routing, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/> <!-- ... --> </httpModules>

To run a Web site with routing in IIS 7.0, you need two entries in web.config. The first entry is the URL routing module configuration, which is found in the <modules> section of <system.webServer>. You also need an entry to handle requests for UrlRouting.axd in the <handlers> section of <system.webServer>. Both of these entries are shown in Figure 2. Also, see the sidebar "IIS 7.0 Configuration Entries."

Figure 2 URL Routing Module Configuration

<system.webServer> <modules runAllManagedModulesForAllRequests="true"> <add name="UrlRoutingModule" type="System.Web.Routing.UrlRoutingModule, System.Web.Routing, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" /> <!-- ... --> </modules> <handlers> <add name="UrlRoutingHandler" preCondition="integratedMode" verb="*" path="UrlRouting.axd" type="System.Web.HttpForbiddenHandler, System.Web, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" /> <!-- ... --> </handlers> </system.webServer>

Once you've configured the URL routing module into the pipeline, it will wire itself to the PostResolveRequestCache and the PostMapRequestHandler events. Figure 3shows a subset of the pipeline events. URL rewriting implementations typically perform their work during the BeginRequest event, which is the earliest event to fire during a request. With URL routing, the route matching and selection of a route handler occurs during the PostResolveRequestCache stage, which is after the authentication, authorization, and cache lookup stages of processing. I will need to revisit the implications of this event timing later in the column.

fig03.gif

Figure 3 HTTP Request

Configuring Routes

Routes and route handlers go hand in hand, but I'll look at the code to configure routes first. The routing engine's RouteTable class exposes a RouteCollection via its static Routes property. You need to configure all of your custom routes into this collection before the application begins to execute the first request, which means you need to use a global.asax file and the Application_Start event.

Figure 4shows the route registration code you need to use for "/recipe/brownies" to reach the RecipeDisplay.aspx Web Form. The parameters for the Add method on the RouteCollection class include a friendly name for the route, followed by the route itself. The first parameter to the Route constructor is a URL pattern. The pattern consists of the URL segments that will appear at the end of a URL pointing to this application (after any segments required to reach the application's root). For an application rooted at localhost/food/ then, the route pattern in Figure 4will match localhost/food/recipe/brownies.

Figure 4 Route Registration Code for /recipe/brownies

protected void Application_Start(object sender, EventArgs e) { RegisterRoutes(); } private static void RegisterRoutes() { RouteTable.Routes.Add( "Recipe", new Route("recipe/{name}", new RecipeRouteHandler( "~/WebForms/RecipeDisplay.aspx"))); }

IIS 7.0 Configuration Entries

The runAllManagedModulesForAllRequests attribute requires a value of true if you want to use the extensionless URLs as I've done in this sample. Also, it might seem strange to configure an HTTP handler for UrlRouting.axd. This is a small workaround that the routing engine requires in order for routing to work under IIS 7.0. The UrlRouting module actually rewrites the incoming URL to ~/UrlRouting.axd, which will rewrite the URL back to the original, incoming URL. It's likely that a future version of IIS will integrate perfectly with the routing engine and not require this workaround.

Segments enclosed inside curly braces denote parameters, and the routing engine will automatically extract the values there and place them into a name/value dictionary that will exist for the duration of the request. Using the previous example of localhost/food/recipe/brownies, the routing engine will extract the value "brownies" and store the value in the dictionary with a key of "name". You'll see how to use the dictionary when I look at the code for the route handler.

You can add as many routes as you need into the RouteTable, but the ordering of the routes is important. The routing engine will test all incoming URLs against the routes in the collection in the order in which they appear, and the engine will select the first route with a matching pattern. For this reason, you should add the most specific routes first. If you added a generic route with the URL pattern "{category}/{subcategory}" before the recipe route, the routing engine would never find the recipe route. One additional note—the routing engine performs the pattern matching in a case-insensitive manner.

Overloaded versions of the Route constructor allow you to create default parameter values and apply constraints. Defaults allow you to specify default values for the routing engine to place into the name/value parameter dictionary when no value exists for the parameter in an incoming URL. For example, you could make "brownies" the default recipe name when the routing engine sees a recipe URL without a name value (like localhost/food/recipe).

Constraints allow you to specify regular expressions to validate parameters and fine-tune the route pattern matching on incoming URLs. If you were using primary key values to identify recipes in a URL (like localhost/food/recipe/5), you could use a regular expression to ensure the primary key value in the URL is an integer. You can also apply constraints using an object that implements the IRouteConstraint interface.

The second parameter to the Route constructor is a new instance of my route handler, which I'll look at in Figure 5.

Figure 5 RecipeRouteHandler

public class RecipeRouteHandler : IRouteHandler { public RecipeRouteHandler(string virtualPath) { _virtualPath = virtualPath; } public IHttpHandler GetHttpHandler(RequestContext requestContext) { var display = BuildManager.CreateInstanceFromVirtualPath( _virtualPath, typeof(Page)) as IRecipeDisplay; display.RecipeName = requestContext.RouteData.Values["name"] as string; return display; } string _virtualPath; }

The Recipe Routing Handler

The following code snippet shows a basic implementation of a route handler for recipe requests. Since the route handler ultimately has to create an instance of an IHttpHandler (in this case, RecipeDisplay.aspx), the constructor requires a virtual path that points to the Web Form the route handler will create. The GetHttpHandler method passes this virtual path to the ASP.NET BuildManager in order to retrieve the instantiated Web Form:

interface IRecipeDisplay : IHttpHandler { string RecipeName { get; set; } }

Notice how the route handler can also pull data from the routing engine's parameter dictionary, which is the RouteData property of the RequestContext class. The routing engine sets up the RequestContext and passes an instance when it invokes this method. There are many options available for getting the route data into the Web Form. You could pass the route data along in the HttpContext Items collection, for instance. In this example, you've defined an interface for your Web Form to implement (IRecipeDisplay). The route handler can set strongly typed properties on the Web Form to pass along any information the Web Form requires, and this approach will work with both the ASP.NET Web site and ASP.NET application compilation models.

Routing and Security

When you're using ASP.NET routing, you can still use all the ASP.NET features you've come to love—Master Pages, output caching, themes, user controls, and more. There is one notable exception, however. The routing module works its magic using events in the pipeline that occur after the authentication and authorization stages of processing, meaning that ASP.NET will be authorizing your users using the public, visible URL and not the virtual path to the ASP.NET Web Form that the route handler selects to process the request. You need to pay careful attention to the authorization strategy for an application using routing.

Let's say you wanted to only allow authenticated users to view recipes. One approach would be to modify the root web.config to use the authorization settings here:

<location path="recipe"> <system.web> <authorization> <deny users="?"/> </authorization> </system.web> </location>

Although this approach will prevent anonymous users from viewing /recipe/tacos, it does have two fundamental weaknesses. First, the setting doesn't prevent a user from directly requesting /WebForms/RecipeDisplay.aspx (although you could add another authorization rule that prevents all users from directly requesting resources from the Web Forms folder). Second, it is easy to change the route configuration in global.asax.cs without changing the authorization rules and leave your secret recipes open to anonymous users.

An alternate approach to authorization would be to protect the RecipeDisplay.aspx Web Form based on its physical location, which is to place web.config files with <authorization> settings directly into the protected folder. However, since ASP.NET is authorizing users based on the public URL, you'll need to make the authorization checks manually on the virtual path that your route handler uses.

You'll need to add the following code to the beginning of your route handler's GetHttpHandler method. This code uses the static CheckUrlAccessForPrincipal method of the UrlAuthorizationModule class (the same module that performs authorization checks in the ASP.NET pipeline):

if (!UrlAuthorizationModule.CheckUrlAccessForPrincipal( _virtualPath, requestContext.HttpContext.User, requestContext.HttpContext.Request.HttpMethod)) { requestContext.HttpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized; requestContext.HttpContext.Response.End(); }

In order to access the HttpContext members via the RequestContext, you'll need to add a reference to the System.Web.Abstractions assembly.

With a secure routing handler in place, you can now turn your attention to the page that needs to generate hyperlinks for each recipe in your database. It turns out the routing logic can help you build this page, too.

URL Generation

To generate the hyperlink to any given recipe, I will once again turn to the collection of routes configured during application startup. As shown here, the RouteCollection class has a GetVirtualPath method for this purpose:

VirtualPathData pathData = RouteTable.Routes.GetVirtualPath( null, "Recipe", new RouteValueDictionary { { "Name", recipeName } }); return pathData.VirtualPath;

You need to pass in the desired route name ("Recipe") along with a dictionary of the required parameters and their associated values. This method will use the URL pattern you created earlier (/recipe/{name}) to construct the proper URL.

The following code uses this method to generate a collection of anonymously typed objects. The objects have Name and Url properties that you can use with data binding to generate a list or table of available recipes:

var recipes = new RecipeRepository() .GetAllRecipeNames() .OrderBy(recipeName => recipeName) .Select(recipeName => new { Name = recipeName, Url = GetVirtualPathForRecipe(recipeName) });

The ability to generate URLs from your routing configuration means you can change the configuration without the fear of creating broken links inside your application. Of course, you still might break your user's favorite links and bookmarks, but having the ability to change is a tremendous advantage when you are still designing the application's URL structure.

Wrapping Up with Routes

The URL routing engine does all of the dirty work of URL pattern matching and URL generation. All you need to do is configure your routes and implement your route handlers. With routing, you are truly isolated from file extensions and the physical layout of your file system, and you don't need to deal with the quirks of using a URL rewriter. Instead, you can concentrate on the optimum URL design for your end users and for search engines. In addition, Microsoft is working on making URL routing with Web Forms even easier and more configurable in the upcoming ASP.NET 4.0.

Send your questions and comments to xtrmasp@microsoft.com.

Scott Allen is a founder of OdeToCode and a member of the Pluralsight technical staff. You can reach Scott at scott@odetocode.com, or read his blog at OdeToCode.com/blogs/scott.