URL Routing Using Attributes - Code Architecture - ASP.NET MVC 5 with Bootstrap and Knockout.js (2015)

ASP.NET MVC 5 with Bootstrap and Knockout.js (2015)

Part III. Code Architecture

Chapter 11. URL Routing Using Attributes

In Chapter 1, I reviewed the default route that allows new controllers and new actions to be created and automatically routed based on their names alone. This is extremely convenient and works most of the time. However, whether it is for Search Engine Optimization (SEO) purposes or to follow a naming convention and provide a more convenient URL, custom routing allows you to do this.

Prior to MVC 5, routes were defined in the RouteConfig (for MVC) and WebApiConfig (for Web Api) and still can be (as the default route is defined). New in MVC 5 is the ability to route via attributes.

Attribute routing is extremely convenient because it helps unhide the routing and makes it more obvious to the developer how the controller and action can be accessed. Global routing, of course, still serves a useful purpose when you have one or two common routes that apply across multiple controllers and/or actions.

Attribute Routing Basics

Before attribute routing can be used, it must be turned on. This is done in the RouteConfig class inside the App_Start folder. Example 11-1 contains an updated defintion of this class.

Example 11-1. Updated RouteConfig

using System;

using System.Collections.Generic;

using System.Linq;

using System.Web;

using System.Web.Mvc;

using System.Web.Routing;

namespace BootstrapIntroduction

{

public class RouteConfig

{

public static void RegisterRoutes(RouteCollection routes)

{

routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

routes.MapMvcAttributeRoutes();

routes.MapRoute(

name: "Default",

url: "{controller}/{action}/{id}",

defaults: new { controller = "Home", action = "Index"

, id = UrlParameter.Optional }

);

}

}

}

Defining a route consists of adding a relative URL from the domain inside of an attribute named Route. A very common scenario where I use routing is for static pages. When the project was first created, a HomeController was created with three actions: Index, About, and Contact. For SEO purposes, it might make more sense for the contact and about pages to reside simply at /About or /Contact instead of /Home/About and /Home/Contact, respectively.

This, of course, could be accomplished by making new controllers called AboutController and ContactController, each with a single action called Index. Although this works, it feels a bit like overkill to create new controllers for static pages with a single action.

Enter routing. Example 11-2 contains an updated HomeController with attribute routes for both the About and Contact actions that remove the requirement for the /Home prefix.

Example 11-2. Updated HomeController

using BootstrapIntroduction.Filters;

using BootstrapIntroduction.ViewModels;

using System;

using System.Collections.Generic;

using System.Linq;

using System.Web;

using System.Web.Mvc;

namespace BootstrapIntroduction.Controllers

{

public class HomeController : Controller

{

public ActionResult Index()

{

return View();

}

[Route("About")]

public ActionResult About()

{

ViewBag.Message = "Your application description page.";

return View();

}

[Route("Contact")]

public ActionResult Contact()

{

ViewBag.Message = "Your contact page.";

return View();

}

}

}

This is one of the most basic examples. Routes can be more complicated. They can define input parameters for your actions, including optional parameters. If you attempted to apply the lessons learned from Part II from the AuthorsController to the BooksController, you may have already created it. If not, you can add the BooksController to the Controllers folder now. Example 11-3 contains a new action in a BooksController with a custom attribute. The action is called ByAuthor and accepts an integer called authorId. Because of the default route, this could be accessed via/Books/ByAuthor/{id}. This example overrides this route and makes the URL look a bit nicer by changing it to /Authors/{id}/Books.

Example 11-3. BooksController

using System;

using System.Collections.Generic;

using System.Data;

using System.Data.Entity;

using System.Linq;

using System.Net;

using System.Web;

using System.Web.Mvc;

using BootstrapIntroduction.DAL;

using BootstrapIntroduction.Models;

namespace BootstrapIntroduction.Controllers

{

public class BooksController : Controller

{

private BookContext db = new BookContext();

[Route("authors/{id}/books")]

public ActionResult ByAuthor(int id)

{

var books = db.Books.Where(b => b.AuthorId == id);

return View(books.ToList());

}

}

}

You might be asking yourself why this is placed in the BooksController and not the AuthorsController. My reasoning behind it is about the resources being displayed, which are books, even though the filter is by author. Likewise, if you were viewing the details of a book and wanted more information on the author, a similar route could be inversed. It would exist in the AuthorsController and be /Books/{id}/Author. Because in this case the resource is the author, and it belongs in the AuthorsController, even though the filter is by book.

In certain scenarios you may wish to make your input parameters optional. This is accomplished by placing a question mark (?) at the end of the parameter name but inside the closing bracket (as shown in Example 11-4).

Example 11-4. Example optional route

[Route("Details/{id?}")]

One final common thing done with attribute routing is to define an alternative default route for the controller. The default route will display the Index method when no action is defined in the URL. With attribute routing, the default route can be updated by placing the Route attribute before the Controller definition as shown in Example 11-5.

Example 11-5. Default controller route

using BootstrapIntroduction.Filters;

using BootstrapIntroduction.ViewModels;

using System;

using System.Collections.Generic;

using System.Linq;

using System.Web;

using System.Web.Mvc;

namespace BootstrapIntroduction.Controllers

{

[Route("{action=About}")]

public class HomeController : Controller

{

// Truncated for example

}

}

This example demonstrates this by updating the HomeController and making the default action the About method.

Route Prefixes

A route prefix allows you to define a common prefix for all actions in your controller. This is quite common when you wish to name your controller to match a pluralized model name, but for SEO (or even readability) purposes, replace it with a different name.

As an example, a synonym for “author” is “writer.” Perhaps to visitors of our website, this is a more commonly understood term. Example 11-6 adds a route prefix to the AuthorsController, changing the previous URLs from /Authors to /Writer.

Example 11-6. Prefix on AuthorsController

using System;

using System.Collections.Generic;

using System.Net;

using System.Web;

using System.Web.ModelBinding;

using System.Web.Mvc;

using BootstrapIntroduction.Models;

using BootstrapIntroduction.ViewModels;

using BootstrapIntroduction.Filters;

using BootstrapIntroduction.Services;

namespace BootstrapIntroduction.Controllers

{

[RoutePrefix("Writer")]

public class AuthorsController : Controller

{

// Truncated for example

}

}

Having a nonpluralized URL works great for actions that include an id parameter at the end, e.g., /writer/details/{id}. However, it doesn’t make a lot of sense when you are going to the Index action and getting a list of writers. Example 11-7 overrides the route prefix for the Index action only of the AuthorsController.

Example 11-7. Updated Index action

[GenerateResultListFilterAttribute(typeof(Author), typeof(AuthorViewModel))]

[Route("~/Writers")]

public ActionResult Index([Form] QueryOptions queryOptions)

{

var authors = authorService.Get(queryOptions);

ViewData["QueryOptions"] = queryOptions;

return View(authors);

}

Overriding the route prefix is accomplished by defining a route and placing a tilde (~) followed by a forward slash (/). If the ~/ was not added, the term writers would be added to the route prefix making the URL /writer/writers.

Routing Constraints

This is my favorite enhancement to routing with MVC 5. Prior to MVC 5, routing constraints required regular expressions. With attribute routing, it has become as simple as specifying a constraint type preceded by a colon (:) after the variable name.

Example 11-8 updates the Details action in the AuthorsController to constrain the id to be an integer.

Example 11-8. Updated AuthorsController

[Route("Details/{id:int?}")]

public ActionResult Details(int? id)

{

if (id == null)

{

return new HttpStatusCodeResult(HttpStatusCode.BadRequest);

}

var author = authorService.GetById(id.Value);

return View(AutoMapper.Mapper.Map<Author, AuthorViewModel>(author));

}

The question mark (?) to mark the id as optional should always be added at the end of the constraints. Notice how I pluralized constraints because they can be chained together. Example 11-9 further updates the Details route to force a minimum value of 0.

Example 11-9. Chaining constraints

[Route("Details/{id:int:min(0)?}")]

public ActionResult Details(int? id)

{

if (id == null)

{

return new HttpStatusCodeResult(HttpStatusCode.BadRequest);

}

var author = authorService.GetById(id.Value);

return View(AutoMapper.Mapper.Map<Author, AuthorViewModel>(author));

}

Certain constraints (such as the min constraint) require input parameters to it, which are done by placing the value within brackets after the constraint.

Chaining the constraints is accomplished by adding a colon (:) before the next constraint.

A complete list of supported constraints is shown in Figure 11-1.

Figure 11-1. Support constraints. This list is courtesy of the MSDN blog

To complete this section, here is a great way I use routing attributes to make both the URLs and Controllers nice and clean. It’s quite common for SEO purposes not to use integers when viewing the details of things like authors or books. Example 11-10 adds two new functions to theAuthorsController (replacing the previous Details method) to display the author either by id or by name.

Example 11-10. Updated AuthorsController

// GET: Authors/Details/5

[Route("Details/{id:int:min(0)?}")]

public ActionResult GetById(int? id)

{

if (id == null)

{

return new HttpStatusCodeResult(HttpStatusCode.BadRequest);

}

var author = authorService.GetById(id.Value);

return View(AutoMapper.Mapper.Map<Author, AuthorViewModel>(author));

}

// GET: Authors/Details/Jamie Munro

[Route("Details/{name}")]

public ActionResult GetByName(string name)

{

if (string.IsNullOrEmpty(name))

{

return new HttpStatusCodeResult(HttpStatusCode.BadRequest);

}

var author = authorService.GetByName(name);

return View(AutoMapper.Mapper.Map<Author, AuthorViewModel>(author));

}

Both the GetById and GetByName functions route to Writer/Details, and because of the constraints, MVC will determine which function to call by parsing out the parameter after Details in the URL. If it is determined to be an integer, it will call the GetById function. Otherwise, it will call theGetByName function.

For this example to compile, the AuthorService needs to be updated to add the new GetByName method, as is shown in Example 11-11.

Example 11-11. Updated AuthorService

using BootstrapIntroduction.Behaviors;

using BootstrapIntroduction.DAL;

using BootstrapIntroduction.Models;

using BootstrapIntroduction.ViewModels;

using System;

using System.Collections.Generic;

using System.Data.Entity;

using System.Linq;

using System.Linq.Dynamic;

using System.Net;

using System.Web;

using System.Web.Mvc;

namespace BootstrapIntroduction.Services

{

public class AuthorService : IDisposable

{

private BookContext db = new BookContext();

public List<Author> Get(QueryOptions queryOptions)

{

var start = QueryOptionsCalculator.CalculateStart(queryOptions);

var authors = db.Authors.

OrderBy(queryOptions.Sort).

Skip(start).

Take(queryOptions.PageSize);

queryOptions.TotalPages = QueryOptionsCalculator.CaclulateTotalPages(

db.Authors.Count(), queryOptions.PageSize);

return authors.ToList();

}

public Author GetById(long id)

{

Author author = db.Authors.Find(id);

if (author == null)

{

throw new System.Data.Entity.Core.ObjectNotFoundException

(string.Format("Unable to find author with id {0}", id));

}

return author;

}

public Author GetByName(string name)

{

Author author = db.Authors

.Where(a => a.FirstName + ' ' + a.LastName == name)

.SingleOrDefault();

if (author == null)

{

throw new System.Data.Entity.Core.ObjectNotFoundException

(string.Format("Unable to find author with name {0}", name));

}

return author;

}

public void Insert(Author author)

{

db.Authors.Add(author);

db.SaveChanges();

}

public void Update(Author author)

{

db.Entry(author).State = EntityState.Modified;

db.SaveChanges();

}

public void Delete(Author author)

{

db.Authors.Remove(author);

db.SaveChanges();

}

public void Dispose() {

db.Dispose();

}

}

}

With the addition of these two new functions, authors can be found in one of two ways:

§ /Writer/Details/1

§ /Writer/Details/Jamie Munro

Summary

Attribute routing is new to MVC 5, which provides a lot of power for creating intelligent routing with a simple mechanism for constraining the data input. It’s nice to have the routing inline with the controllers because it can be difficult to understand why a controller method cannot be found when the routing is at a more global level. With attribute routing, the route is defined in the same code as the controller method that you are working on.