ASP.NET MVC: Routing custom slugs without affecting performance - c#

I would like to create custom slugs for pages in my CMS, so users can create their own SEO-urls (like Wordpress).
I used to do this in Ruby on Rails and PHP frameworks by "abusing" the 404 route. This route was called when the requested controller could not be found, enabling me te route the user to my dynamic pages controller to parse the slug (From where I redirected them to the real 404 if no page was found). This way the database was only queried to check the requested slug.
However, in MVC the catch-all route is only called when the route does not fit the default route of /{controller}/{action}/{id}.
To still be able to parse custom slugs I modified the RouteConfig.cs file:
public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
RegisterCustomRoutes(routes);
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { Controller = "Pages", Action = "Index", id = UrlParameter.Optional }
);
}
public static void RegisterCustomRoutes(RouteCollection routes)
{
CMSContext db = new CMSContext();
List<Page> pages = db.Pages.ToList();
foreach (Page p in pages)
{
routes.MapRoute(
name: p.Title,
url: p.Slug,
defaults: new { Controller = "Pages", Action = "Show", id = p.ID }
);
}
db.Dispose();
}
}
This solves my problem, but requires the Pages table to be fully queried for every request. Because a overloaded show method (public ViewResult Show(Page p)) did not work I also have to retrieve the page a second time because I can only pass the page ID.
Is there a better way to solve my problem?
Is it possible to pass the Page object to my Show method instead of the page ID?

Even if your route registration code works as is, the problem will be that the routes are registered statically only on startup. What happens when a new post is added - would you have to restart the app pool?
You could register a route that contains the SEO slug part of your URL, and then use the slug in a lookup.
RouteConfig.cs
routes.MapRoute(
name: "SeoSlugPageLookup",
url: "Page/{slug}",
defaults: new { controller = "Page",
action = "SlugLookup",
});
PageController.cs
public ActionResult SlugLookup (string slug)
{
// TODO: Check for null/empty slug here.
int? id = GetPageId (slug);
if (id != null) {
return View ("Show", new { id });
}
// TODO: The fallback should help the user by searching your site for the slug.
throw new HttpException (404, "NotFound");
}
private int? GetPageId (string slug)
{
int? id = GetPageIdFromCache (slug);
if (id == null) {
id = GetPageIdFromDatabase (slug);
if (id != null) {
SetPageIdInCache (slug, id);
}
}
return id;
}
private int? GetPageIdFromCache (string slug)
{
// There are many caching techniques for example:
// http://msdn.microsoft.com/en-us/library/dd287191.aspx
// http://alandjackson.wordpress.com/2012/04/17/key-based-cache-in-mvc3-5/
// Depending on how advanced you want your CMS to be,
// caching could be done in a service layer.
return slugToPageIdCache.ContainsKey (slug) ? slugToPageIdCache [slug] : null;
}
private int? SetPageIdInCache (string slug, int id)
{
return slugToPageIdCache.GetOrAdd (slug, id);
}
private int? GetPageIdFromDatabase (string slug)
{
using (CMSContext db = new CMSContext()) {
// Assumes unique slugs.
Page page = db.Pages.Where (p => p.Slug == requestContext.Url).SingleOrDefault ();
if (page != null) {
return page.Id;
}
}
return null;
}
public ActionResult Show (int id)
{
// Your existing implementation.
}
(FYI: Code not compiled nor tested - haven't got my dev environment available right now. Treat it as pseudocode ;)
This implementation will have one search for the slug per server restart. You could also pre-populate the key-value slug-to-id cache at startup, so all existing page lookups will be cheap.

Related

How to register new route from the controller action method?

I have to develop some pages and public handles (aka, aliases) for those pages.
(To get the idea: in the facebook you can have alias for your page and the final URL will look like facebook/alias instead of facebook/somelongpieceofsomestuff).
I store public handle in the db table and make sure that all handles are unique.
Now I've added routing registration for my handles:
public override void RegisterArea(AreaRegistrationContext context)
{
// Assume, that I already have dictionary of handles and ids for them
foreach(var pair in publicHandlesDictionary)
{
var encId = SomeHelper.Encrypt(pair.Key);
context.MapRoute(pair.Value, pair.Value,
new {controller = "MyController", action="Index", id = encId});
}
}
So, now I can reach some page by using address http://example.com/alias1 instead of http://example.com/MyController/Index&id=someLongEncryptedId.
And this stuff works fine, ok.
But what if I start the applicatian, then add new handle? This new handle will not be registered, because all routes registration are performed when app is started. Basically, I have to restart the application (the IIS, the VS/IIS Express, the Azure, doesn't matter) to get all routes be registered again including my new handle.
So, is there any way to add new route registration from the controller's action method (when new handle is added)?
you dont need to create all routes at app start.
just use IRouteConstraint to determine what should follow alias logic
public class AliasConstraint : IRouteConstraint
{
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
var alias = values[parameterName];
// Assume, that I already have dictionary of handles and ids for them
var publicHandlesDictionary = SomeStaticClass.Dic;
if (publicHandlesDictionary.ContainsValue(alias))
{
//adding encId as route parameter
values["id"] = SomeHelper.Encrypt(publicHandlesDictionary.FirstOrDefault(x => x.Value == alias).Key);
return true;
}
return false;
}
}
//for all alias routes
routes.MapRoute(
name: "Alias",
url: "{*alias}",
defaults: new {controller = "MyController", action = "Index"},
constraints: new { alias = new AliasConstraint() }
);
//for all other default operations
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
this way you can update publicHandlesDictionary anytime and route will pick up the changes

ASP.NET MVC. Routing manager. Resolve routes issues with similar link values

I need advice about a problem with which I recently encountered. The essence of the problem: in my web site I have a Controller. Let it be ProductController. And, of course, I have some Actions in it:
public class ProductController : Controller
{
public virtual ActionResult ProductDetails(string slug)
public virtual ActionResult ProductList (string slug, string category, string filter)
}
Filter parameter can be empty. My routes in RouteConfig:
routes.MapRoute("ProudctsList", "products/{slug}/{category}/{selectedFilters}", MVC.Product.ProductList()
.AddRouteValue("selectedFilters", ""));
routes.MapRoute("Products", "products/{slug}", MVC.Product.ProductDetails());
routes.MapRoute("CustomPage", "custom/{slug}", MVC.CustomPage.Index());
......
routes.MapRoute("BrandCollectionDetails", "brand/{slug}/{collectionId}", MVC.Brand.BrandCollectionDetails());
routes.MapRoute("BrandDetails", "brand/{slug}", MVC.Brand.BrandDetails());
routes.MapRoute("HomePage", "", MVC.Home.Index());
routes.MapRoute("Sales.Index", "sales", MVC.Sales.Index());
According to requirements I need to remove products from the Url. I mean that if Url has such scheme product/cars/audi/black - it should be just cars/audio/black. And also I need to remove custom from the Url. As you could see, I'll get Urls with the same scheme. I need to say that I have I List of custom Urls:
List<string> customPages = new List<string> {/Ferrari, ...}
I mean that all Urls with such scheme: /{something} should be checked using this List.
I just need to remove products/ segment from this Urls products/{slug}/{category}/{filter} and custom from this custom/{slug} and direct user to the necessary Controller Action. I have no ideas how to build custom route manager that should resolve this issues and need experts help. Please, tell me if the issue is clear.
There are several ways to solve this problem. It looks like your specific problem can be solved by creating an implementation of the IRouteConstraint.
This will allow you to dynamically override the default routing functionality of your application.
public class ProductUrlConstraint : IRouteConstraint
{
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
List<string> customPages = new List<string> {"Ferrari", "Porche"};
if (values[parameterName] != null)
{
var slug= values[parameterName].ToString();
var product = customPages.Where(p => p == slug).FirstOrDefault();
if(!string.isNullorEmpty(product))
{
HttpContext.Items["customProduct"] = product;
return true;
}
}
return false;
}
}
Use your implementation of the IRouteConstraint in your route definition as follows:
routes.MapRoute(
name: "ProductRoute",
url: "{*customProduct}",
defaults: new {controller = "Product", action = "Index"},
constraints: new { customProduct= new ProductUrlConstraint() }
);
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
The action would then follow like:
public ActionResult Index(string permalink)
{
var page = HttpContext.Items["customProduct"].ToString();
//dont forget to check for null :)
//model/ view logic
}
You might have to do some custom string processing within your action depending on your later requirements.
Storing the custom product in the HttpContext is simply a performance consideration.

Different landing page with MVC 4

I'm using MVC 4 and having problems with the landing page.
I have two kinds of users (let's call the FooUser and BarUser)
Each of the users has it's own landing page:
Foo/Index and Bar/Index
Once user logs in, I can identify whether he is Foo or Bar and redirect him to the relevant page.
But I still have a problem and that is when a user opens the main page. In this case the user doesn't perform a login action (since he is logged in from the previous session) so I can't redirect him to the relevant page.
Is there a way to set conditional defaults? something like:
(Any other ideas are most welcome)
if (IsCurrentUserFooUser()) //Have no idea how to get the current user at this point in the code
{
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Foo", action = "Index", id = UrlParameter.Optional });
}
else
{
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Bar", action = "Index", id = UrlParameter.Optional });
}
It might be worth considering if you really need a new controller for different users. Why not just return a different view and do some logic in the controller. This would be my preferred route as it's less overhead then dynamically calculating routes.
Routes are mapped when the application starts so it won't be able to do conditional ones. You could use a dynamic routes which are processed per request so you can do some logic to see if that route matches.
Note: return null at any point in the dynamic route to cancel it and make it invalid for that request.
public class UserRoute: Route
{
public UserRoute()
: base("{controller}/{action}/{id}", new MvcRouteHandler())
{
}
public override RouteData GetRouteData(HttpContextBase httpContext)
{
var rd = base.GetRouteData(httpContext);
if (rd == null)
{
return null;
}
//You have access to HttpContext here as it's part of the request
//so this should be possible using whatever you need to auth the user.
//I.e session etc.
if (httpContext.Current.Session["someSession"] == "something")
{
rd.Values["controller"] = "Foo"; //Controller for this user
rd.Values["action"] = "Index";
}
else
{
rd.Values["controller"] = "Bar"; //Controller for a different user.
rd.Values["action"] = "Index";
}
rd.Values["id"] = rd.Values["id"]; //Pass the Id that came with the request.
return rd;
}
}
This could then be used like this:
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.Add("UserRoute", new UserRoute());
//Default route for other things
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
}

Routing guid coming up as null

I have an hybrid application which is classic asp.net and MVC both. It is asp.net 4.5 and MVC 4.
Problem:
From the route, i can get to the action just fine but the guid is always coming up as null. Do you see any thing that i may be missing in my implementation? Problem is with SiteMyHome route.
Route
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{*favicon}", new { favicon = #"(.*/)?favicon.ico(/.*)?" });
//Ignore calls to httphandlers
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(
RouteConstants.SiteMyHome,
RouteConstants.SiteMyHome + "/{guid}/{ccode}/{tab}",
new { controller = ControllerNames.SiteRouter, action = ActionNames.SiteMyHome, ccode = UrlParameter.Optional, tab = UrlParameter.Optional }
);
//Set the default route
routes.MapRoute(
"Default", // Route name
"{controller}/{action}/{id}", // URL with parameters
new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
);
}
Action
public ActionResult SiteMyHome(Guid? guid, string ccode, string tab)
{
return null;
}
Test
/SiteMyHome/9c95eb0-d8fb-4176-9de0-5b99f8b914db/test/matched
When i debug my action, i get the following
guid = null
ccode = test
tab = matched
Why is guid getting passed as null here?
Now, when i change the action to string guid then i get its value just fine (image attached). I am confused...
public ActionResult SiteMyHome(string guid, string ccode, string tab)
It was a guid issue. The guid that i was testing with is not a valid guid where as d04e9071-0cdf-4aa8-8f34-b316f9f3c466 is resulting in a valid guid.

Make page url to be page title

What would be the easiest way to make a page title to be the url?
Currently I have:
http://localhost:53379/Home/Where
http://localhost:53379/Home/About
http://localhost:53379/Home/What
and would like to have
http://localhost:53379/where-to-buy
http://localhost:53379/about-us
http://localhost:53379/what-are-we
I thought about adding a route to each page (there's only 9 pages) but I wonder if there's something better, for example for big sites.
routes.MapRoute(
name: "Default",
url: "where-to-buy",
defaults: new {
controller = "Home",
action = "Where",
id = UrlParameter.Optional
}
);
...
and I would like to have it in English and Local language as well, so adding more routes would not make that much sense...
If you need to fetch pages dynamically from the database, define a new route which will catch all requests. This route should be defined last.
routes.MapRoute(
name: "Dynamic",
url: "{title}",
defaults: new {
controller = "Home",
action = "Dynamic",
title = ""
}
)
Then in your controller:
public class HomeController {
public ActionResult Dynamic(string title) {
// All requests not matching an existing url will land here.
var page = _database.GetPageByTitle(title);
return View(page);
}
}
Obviously all pages need to have a title (or slug, as it's commonly referred to) defined.
If you have static actions for each page, you could use AttributeRouting. It will allow you to specify the route for each action using an attribute:
public class SampleController : Controller
{
[GET("Sample")]
public ActionResult Index() { /* ... */ }
[POST("Sample")]
public ActionResult Create() { /* ... */ }
[PUT("Sample/{id}")]
public ActionResult Update(int id) { /* ... */ }
[DELETE("Sample/{id}")]
public string Destroy(int id) { /* ... */ }
[Route("Sample/Any-Method-Will-Do")]
public string Wildman() { /* ... */ }
}
I use it on a mid-sized project and it's working pretty well. The big win is that you always know where your routes are defined.

Categories