I’m building a website using ASP.NET Core. The site serves the same content types, but with unique content, for different cities. The site also serves content that isn’t city-specific, like the site’s “About” and “Contact” pages, and its administrative interface.
URLs for city-specific content should include the city name for SEO benefits, as seen in these examples:
http://example.com/new-york-ny/news http://example.com/los-angeles-ca/news
After exploring different ways to handle the city-specific content, I implemented the feature by customizing ASP.NET Core’s route constraints and model binding. Read on for an explanation and example code of how it works.
The source code seen in this article is available at https://github.com/tbaggett/citySiteSample.
The Obvious Approach
The easy way to handle the per-city content is adding a custom route that passes the market name to action methods as a string parameter, like so:
Startup.cs Configure method excerpt:
public void Configure(IApplicationBuilder app) { app.UseMvc(routes => { // Routes requests for front end city-specific content routes.MapRoute( name: "city", template: "{cityInfo}/{controller}/{action=Index}/{id?}"); routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); }); }
This route will direct our example URLs to the NewsController’s Index method, which expects a string parameter called “cityInfo”. ASP.NET Core populates the parameter via its model binding mechanism.
NewController.cs Index method excerpt:
public class NewsController : Controller { public ActionResult Index(string cityInfo) { // Do something with the "cityInfo" string parameter to fetch the unique content } }
This approach results in a couple of problems. First, there hasn’t been any validation of the cityInfo string parameter when the action method is called. What if the city’s name is misspelled, the state abbreviation is missing, or an unsupported city is specified?
Validation is clearly needed, which exposes this approach’s second problem. We’ll have to validate the passed string parameter in every action method that delivers city-specific content. If the parameter’s value is valid, we will also need to perform whatever operations are necessary to fetch the city’s unique content.
We can reduce the amount of code in our action methods to a few lines by placing the validation and data retrieval code in a separate helper class/method as seen below, but this approach still gets out of hand as our site grows.
public class NewsController : Controller { public ActionResult Index(string cityInfo) { // Validation/data retrieval code moved to separate CityHelper class CityNewsViewModel model = CityHelper.VerifyNewsUrlFragment(cityInfo); if (model != null) { // Passed city is valid, return requested content return View(model); } // Else the passed city wasn't valid, so redirect user to our home page return Redirect("/"); } }
The Better Approach
In my opinion, the better way to implement this feature is to:
- Validate the city URL fragment by using a custom route constraint. This step prevents an action method from ever being called if an invalid city is specified
- Provide high-value data when calling action methods by using custom model binding. This step minimizes code being duplicated in action methods
Lets review how I implemented this approach.
Validating URL Fragments With Custom Route Constraints
Our first step is validating the cityInfo URL fragment. We do this by creating a custom implementation of ASP.NET Core’s IRouteConstraint interface, then adding it to the constraint map specified in ASP.NET Core’s route options.
ASP.NET Core’s IRouteConstraint interface:
namespace Microsoft.AspNetCore.Routing { // // Summary: // /// Defines the contract that a class must implement in order to check whether // a URL parameter /// value is valid for a constraint. /// public interface IRouteConstraint { // // Summary: // /// Determines whether the URL parameter contains a valid value for this constraint. // /// // // Parameters: // httpContext: // An object that encapsulates information about the HTTP request. // // route: // The router that this constraint belongs to. // // routeKey: // The name of the parameter that is being checked. // // values: // A dictionary that contains the parameters for the URL. // // routeDirection: // /// An object that indicates whether the constraint check is being performed // /// when an incoming request is being handled or when a URL is being generated. // /// // // Returns: // true if the URL parameter contains a valid value; otherwise, false. bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection); } }
The IRouteConstraint interface specifies a single Match method that must be implemented. The method returns a boolean result, which indicates if the URL fragment being tested passed the constraint’s evaluation.
There are a number of parameters passed to IRouteConstraint’s Match method. We’ll review the ones we use in our custom constraint implementation, beginning with the routeKey string parameter. It specifies the route fragment that is being tested. In the case of the route we added earlier, routeKey will be assigned a value of “cityInfo”.
The next parameter we use is the values RouteValueDictionary parameter. Its a dictionary that maps a key of type string to a value of type object. The key is the name specified in a route. The value references the value found in the provided URL. An example may help to clarify this one.
This route…
{cityInfo}/{controller}/{action=Index}/{id?}
…and this Url…
http://example.com/new-york-ny/news
…produce this RouteValueDictionary instance:
values = new RouteValueDictionary() { { "cityInfo", "new-york-ny" }, { "controller", "news" }, { "action", "Index" }, { "id", null } };
By using the value of routeKey (“cityInfo”) as our key, the values dictionary returns “new-york-ny” as the cityInfo URL fragment.
Those are the only method parameters that I need to access in my implementation of the IRouteConstraint interface. If you need to reference any of the other passed parameters in your implementation, see the Microsoft documentation for more details. Next, lets take a look at my implementation of the interface.
My implementation of ASP.NET Core’s IRouteConstraint interface:
public class CityRouteConstraint : IRouteConstraint { public const string ROUTE_LABEL = "cityRouteFragment"; public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) { return CityInfo.Cities.ContainsKey(values[routeKey]?.ToString().ToLowerInvariant()); } }
While my list of supported cities may eventually be stored in a database so they can be easily modified by a site administrator, I’m using a static Cities dictionary in this example. The different supported cities’ URL fragments are mapped to custom data class instances. We’ll see how those data instances are provided to the action methods later in this article.
The dictionary that maps city URL fragments to data class instances:
public static class CityInfo { public static readonly Dictionary<string, ICityInfo> Cities = new Dictionary<string, ICityInfo>() { { "new-york-ny", new NewYorkNY() }, { "los-angeles-ca", new LosAngelesCA() } }; }
As mentioned before, the RouteValueDictionary class maps a string to a generic object. For my custom route constraint implementation, I need to cast the generic object to a string. I also want to ignore upper/lowercase variations of the string content. Here’s the part of the code that does that.
values[routeKey]?.ToString().ToLowerInvariant()
In the above code, null is returned if no key is found with the name specified by the routeKey parameter, or if one was found but its assigned value was null. If this is the case, the ToString() and ToLowerInvariant methods aren’t called, thanks to C# 6.0’s null-conditional operator (“?”). Otherwise, the generic object instance returned by the values RouteValueDictionary is explicitly converted to a string, which is then explicitly converted to lowercase content.
Once the city name to be tested has been retrieved, the static Cities dictionary’s ContainsKey method is used to return the boolean value expected from the Match method. It will return true if a key that matches the specified city is found in the dictionary, or false if it isn’t.
Registering Our Custom Route Constraint
The next step is letting ASP.NET Core know about the new constraint. We take care of that by adding an entry to the constraint map found in ASP.NET Core’s routing options. This is done in your project’s ConfigureServices method in your Startup class:
Startup.cs ConfigureServices method excerpt:
public void ConfigureServices(IServiceCollection services) { services.Configure<RouteOptions>(options => { options.ConstraintMap.Add(CityRouteConstraint.ROUTE_LABEL, typeof(CityRouteConstraint)); }); }
Once the constraint is registered, we can modify our route declaration to use it like so:
public void Configure(IApplicationBuilder app) { app.UseMvc(routes => { // Routes requests for front end city-specific content routes.MapRoute( name: "city", template: "{cityInfo:" + CityRouteConstraint.ROUTE_LABEL + "}/{controller}/{action=Index}/{id?}"); routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); }); }
With those changes in place, our custom constraint will be called upon to verify the cityInfo URL fragment. If the constraint returns false, the route isn’t considered to be a match. ASP.NET Core then proceeds to the next assigned route if one exists.
Extending ASP.NET Core’s Model Binding Process
Now that we have our URL validation out of the way, we need to tackle the second part of how I want my solution to work, which is directly passing the matching ICityInfo data instance as a parameter to action methods, like this example.
public class NewsController : Controller { public ActionResult Index(ICityInfo cityInfo) => View(cityInfo.CityNewsViewModel); }
In my opinion, this version is a huge win over our earlier implementation of the Index action method. Lets review how to make this work in ASP.NET Core.
ASP.NET Core’s default model binding can convert a variety of URL elements. This Microsoft documentation is a great starting point if you’re not familiar with everything it can do. However, I didn’t see a way of passing my Cities dictionary’s matching ICityInfo entries as an action method parameter to methods that needed to access it.
Fortunately, Microsoft has made ASP.NET Core very extensible. We can add the desired support for passing our ICityInfo instances to action methods by implementing and registering our own implementations of ASP.NET Core’s IModelBinderProvider and IModelBinder interfaces similarly to how we handled the IRouteConstraint interface earlier in this article.
ASP.NET Core’s IModelBinderProvider interface:
namespace Microsoft.AspNetCore.Mvc.ModelBinding { // // Summary: // /// Creates Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder instances. Register // Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinderProvider /// instances in MvcOptions. // /// public interface IModelBinderProvider { // // Summary: // /// Creates a Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder based on Microsoft.AspNetCore.Mvc.ModelBinding.ModelBinderProviderContext. // /// // // Parameters: // context: // The Microsoft.AspNetCore.Mvc.ModelBinding.ModelBinderProviderContext. // // Returns: // An Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder. IModelBinder GetBinder(ModelBinderProviderContext context); } }
Starting with the IModelBinderProvider interface, I created a custom implementation that implements that interface’s GetBinder method.
My custom IModelBinderProvider implementation
public class CityInfoModelBinderProvider : IModelBinderProvider { public IModelBinder GetBinder(ModelBinderProviderContext context) { if (context == null) { throw new ArgumentNullException(nameof(context)); } // We only want to invoke our custom provider for ICityInfo implementors if (context.Metadata?.ModelType == typeof(ICityInfo)) { return new CityInfoModelBinder(); } return null; } }
In the above code, I added a couple of sanity checks, first throwing an exception if the context wasn’t provided—it should always be provided. Next, I verify the desired data type is an implementor of my ICityInfo interface before returning an instance of my custom model binder.
ASP.NET Core’s IModelBinder interface:
namespace Microsoft.AspNetCore.Mvc.ModelBinding { // // Summary: // /// Defines an interface for model binders. /// public interface IModelBinder { // // Summary: // /// Attempts to bind a model. /// // // Parameters: // bindingContext: // The Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext. // // Returns: // /// // /// A System.Threading.Tasks.Task which will complete when the model binding // process completes. /// // /// // /// If model binding was successful, the Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext.Result // should have /// Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingResult.IsModelSet // set to true. /// // /// // /// A model binder that completes successfully should set Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext.Result // to /// a value returned from Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingResult.Success(System.Object). // /// // /// Task BindModelAsync(ModelBindingContext bindingContext); } }
Next up, I implemented the IModelBinder interface and its BindModelAsync method.
My custom IModelBinder implementation
public class CityInfoModelBinder : IModelBinder { public Task BindModelAsync(ModelBindingContext context) { if (context == null) { throw new ArgumentNullException(nameof(context)); } string cityFragment = context.ActionContext.RouteData.Values[context.FieldName]? .ToString().ToLowerInvariant(); // Assign the matched city info if found if (cityFragment != null && CityInfo.Cities.ContainsKey(cityFragment)) { context.Result = ModelBindingResult.Success(CityInfo.Cities[cityFragment]); } return Task.CompletedTask; } }
Again, I added a couple of sanity checks, verifying the context was provided and that a matching ICityInfo instance was found. A matching ICityInfo instance will always be found if our custom CityRouteConstraint constraint was used. This check protects against the constraint not being applied in the route.
The key part of the method is the assignment of the matching city info to the context’s Result property. With that assignment in place, ASP.NET Core’s model binding mechanism will pass the ICityInfo instance to any action method that requests it as a parameter. Or at least it will once I register my custom implementation of the IModelBinderProvider interface.
Registering My Implementation of the IModelBinderProvider Interface
The final step in making things work as I want them requires revisiting our Startup class’s ConfigureServices method.
Startup.cs ConfigureServices method excerpt:
public void ConfigureServices(IServiceCollection services) { services.AddMvc().AddMvcOptions(options => { options.ModelBinderProviders.Insert(0, new CityInfoModelBinderProvider()); }); }
I insert my custom binder provider at the beginning of the ModelBinderProviders list. While this isn’t required for things to work as desired, I expect the custom provider will be used to handle the majority of my site’s requests, so it may improve the site’s performance. Of course, that will need to be load tested to know for sure.
Thats a Wrap
That does it! The routing of city-specific content is now working on the site as I wanted it to. Here are screen shots of this article’s sample code showing results for Los Angeles and New York City.
I can imagine other uses for these techniques as well. For example, lets say you’re building a site that includes identical sections for every department in your organization. Each section may have news, events, FAQ pages, discussion forums, etc., with clean URLs to access them like “http://example.com/marketing/faq” and “http://example.com/engineering/events”. Using the techniques described here will handle this for you in a clean, reusable way.
I hope you enjoyed the article. Do you know a better way to address this with ASP.NET Core? Please let me know if so. I’m always interested in learning new things! You’re also welcomed to subscribe to my blog if you’d like for articles like this one to be delivered to your inbox.
Until next time,
Leave a Reply