Wednesday, April 10, 2013

Handling 404 Error in ASP.NET MVC

In ASP.NET Web Forms, handling 404 errors are easy - which is basically a web.config setting. In ASP.NET MVC, it is a bit more complicated. Why is it more complicated? In comparison, everything is seemingly easier in MVC than WebForm.

It is more complicated mainly because of Routing. In WebForm, most 404 occurs because of non-existent file and each UR: is usually mapped to a particular file (aspx). With MVC, that is not the case. All requests are handled by the Routing table and based on that it will invoke appropriate controller and actions etc. Secondly, our basic default route usually is quite common ({controller}/{action}/{id}) - therefore most URL request will be caught by this route.

So, let's dive in on how can we do proper handling of 404 errors with ASP.NET MVC.

TURN ON CUSTOM ERROR IN WEB.CONFIG

    <customErrors mode="On" defaultRedirect="~/Error/Error">
      <error statusCode="404" redirect="~/Error/Http404" />
    </customErrors>

DECLARE  DETAIL ROUTES MAPPED IN ROUTE TABLE

So instead of just using the default route:
   routes.MapRoute(
      name: "Default",
      url: "{controller}/{action}/{id}",
      defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
   );
Declare all your intended route explicitly and create a "catch-all" to handle non-matching route - which basically a 404. In MVC, a 404 can happen when you try to access a URL where the there is no controller for. This code in the routing table handles that scenario.
   routes.MapRoute(
      name: "Account",
      url: "Account/{action}/{id}",
      defaults: new { controller = "Account", action = "Index", id = UrlParameter.Optional }
   );

   routes.MapRoute(
      name: "Home",
      url: "Home/{action}/{id}",
      defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
   );

   routes.MapRoute(
      name: "Admin",
      url: "Admin/{action}/{id}",
      defaults: new { controller = "Admin", action = "Index", id = UrlParameter.Optional }
   );

   routes.MapRoute(
      name: "404-PageNotFound",
      url: "{*url}",
      defaults: new { controller = "Home", action = "Http404" }
   );

OVERRIDE HANDLEUNKNOWNACTION IN BASECONTROLLER CLASS

Create a Controller base class that every controller in your application inherits from. In that base controller class, override HandleUnknownAction method. Now, another scenario that a 404 may happen is that when the controller exists, but there is no action for it. In this case, the routing table will not be able to trap it easily - but the controller class has a method that handle that.
    [AllowAnonymous]
    public class ErrorController : Controller
    {
        protected override void HandleUnknownAction(string actionName)
        {
            if (this.GetType() != typeof(ErrorController))
            {
                var errorRoute = new RouteData();
                errorRoute.Values.Add("controller", "Error");
                errorRoute.Values.Add("action", "Http404");
                errorRoute.Values.Add("url", HttpContext.Request.Url.OriginalString);
 
                View("Http404").ExecuteResult(this.ControllerContext);
            }
        }
 
        public ActionResult Http404()
        {
            return View();
        }
 
        public ActionResult Error()
        {
            return View();
        }
    }

CREATE CORRESPONDING VIEWS

View for generic error: Error.chtml
@model System.Web.Mvc.HandleErrorInfo
 
@{
    ViewBag.Title = "Error";
}
 
<hgroup class="title">
    <h1 class="error">Error.</h1>
    <br />
    <h2 class="error">An error occurred while processing your request.</h2>
    @if (Request.IsLocal)
    {
        <p>
            @Model.Exception.StackTrace
        </p>
    }
    else
    {
        <h3>@Model.Exception.Message</h3>
    }
</hgroup>
View for 404 error: Http404.chtml
@model System.Web.Mvc.HandleErrorInfo
 
@{
    ViewBag.Title = "404 Error: Page Not Found";
}
<hgroup class="title">
    <h1 class="error">We Couldn't Find Your Page! (404 Error)</h1><br />
    <h2 class="error">Unfortunately, the page you've requested cannot be displayed. </h2><br />
    <h2>It appears that you've lost your way either through an outdated link <br />or a typo on the page you were trying to reach.</h2>    
</hgroup>
-- read more and comment ...

Sunday, April 7, 2013

Properly Deleting Database for Database-Migration

I am working on a simple brand new project with ASP.NET MVC and decided to try EF to connect to my database. This gives me the opportunity to learn EF Code-First, database-migration, etc.

Everything seems to be pretty intuitive until when I am running "update-database" from the Package Manager Console. I created my Configuration class, turn-on automatic migration, and populated my database using Seed method, made sure all my context are correct.

[TL;DR]

When one need to delete/recreate a database, do not delete the mdf file from App_Data, but instead go to "SQL Server Object Explorer" and find your database under (localdb) and delete it from there.

FULL VERSION:


Then, since I want to recreate my database, I delete my database (mdf file) from my App_Data folder under Solution Explorer and then run "update-database". See picture on the left.

But then I am getting an error:
Cannot attach the file D:\Projects\MvcApplication1\App_Data\aspnet-MvcApplication1-20130407085115.mdf' as database 'MvcApplication1'.
I looked in the file explorer and the mdf file is surely gone. Try to close Visual Studio and reopen, same error.

Well, the database was initially created when I try to "Register" or create an account using the site (it's using SimpleMembershipProvider) - so maybe it will recreate it if I simply run the site and try to register again. But then I am getting the same error when running the website.

I went to the recycle bin, restore the mdf file and ran the project again - it worked. It did not have the new tables or new data, but no "cannot attach" error. Restoring this file also restore my default connection. If I try to delete the mdf file again, then my project won't run and my database-migration also won't run.

I almost resort to think that "Code-First" is a lie - that I simply have to add the new tables manually in the SQL table designer, etc. This is so confusing - should not be that hard, I think.

So with the mdf file deleted, I went to Server Explorer and checked that my connection to the database is gone - there is nothing under "Data Connections".

So maybe I need to delete the data connection instead of deleting the mdf file from App_Data? So I did a restore again from my Recycle Bin, made sure my project ran, and then I deleted my connection from the Server Explorer, rebuilt the project and ran it. It worked! But my delight is short-lived, since then I realized that deleting the connection does not necessarily mean deleting the database - which I quickly checked that my mdf file still in the App_Data folder. I simply deleted the connection to view the database via Visual Studio.

I tried more and more things - which increase the frustration level - because at this point I am stuck in my project and all I have been trying to do is to modify my database schema. If I did this using the old way using SQL Management Studio or SQL Express, or even Linq-to-SQL, I could have been making a huge progress in my project.

SOLUTION

Until I stumbled on "SQL Server Object Explorer" under "VIEW" in your VS Studio 2012 top menu/toolbar. I noticed that although my mdf file is deleted from my App_Data folder, but under "SQL Server Object Explorer", my database is still registered under (localdb)\v11.0\Databases.

Out of curiosity, I deleted my database from there and re-ran my project - it worked. It recreated my database (without the new tables and seed data). So I deleted it again from SQL Object Explorer, and ran "update-database" - it ran successfully this time. EUREKA!!

So lesson learned: when one need to delete/recreate a database, do not delete the mdf file from App_Data, but instead go to "SQL Server Object Explorer" and find your database under (localdb) and delete it from there.

-- read more and comment ...

Monday, April 1, 2013

Crumbtrail ActionFilter

Recently, I had to make a crumb-trail in the web application that I am working on (ASP.NET MVC). There are multiple ways of doing this and initially I elected to do this in my controller base class (which is inherited by all my controller classes). I created a method that do the job - but this means that this method has to be called on every single action (with GET method). If a fellow developer miss to call the method, then it would mean that the data in the crumb-trail is not built properly or accurately. If there is just an interceptor that I can hook into that will run automatically every time a controller action is being called ... *sigh

Wait - there is one, ActionFilter!!

So I created an action filter and via configuration register and apply it to all controllers. The logic in my code handles the exceptions to the apply all (such as Account controller, POST method, and non-authenticated users). Here is the code to the ActionFilter code:
    public class CrumbTrailKeyValuePair<TKey, TValue>
    {
        public CrumbTrailKeyValuePair() { }
        public CrumbTrailKeyValuePair(TKey key, TValue value)
        {
            Key = key;
            Value = value;
        }
        public TKey Key { get; set; }
        public TValue Value { get; set; }
    }

    public class CrumbTrailAttribute : ActionFilterAttribute
    {
        public override void OnResultExecuting(ResultExecutingContext filterContext)
        {
            if (filterContext.HttpContext.User.Identity.IsAuthenticated)
            {
                // skip recording Account controller actions
                if (filterContext.RouteData.Values["controller"] != null && 
                    filterContext.RouteData.Values["controller"].ToString() != "Account")
                {
                    // put in cookies
                    Queue<CrumbTrailKeyValuePair<string, string>> crumbTrailQueue = 
                       new Queue<CrumbTrailKeyValuePair<string, string>>();
                    HttpCookie crumbTrailCookie = 
                       filterContext.HttpContext.Request.Cookies["CrumbTrailLinks"] ?? new HttpCookie("CrumbTrailLinks");
 
                    // initialize serializer
                    var serializer = new JavaScriptSerializer();
 
                    // if crumbTrailCookie is not empty, retrieve value from cookie the rehydrate queue
                    if (crumbTrailCookie != null && !string.IsNullOrEmpty(crumbTrailCookie.Value))
                    {
                        // rehydrate crumbTrailQueue with cookie value         
                        crumbTrailQueue = 
                           new Queue<CrumbTrailKeyValuePair<string, string>>
                              (serializer.Deserialize<IEnumerable<CrumbTrailKeyValuePair<string, string>>>
                                 (HttpUtility.UrlDecode(crumbTrailCookie.Value)));
                    }
 
                    if (filterContext.HttpContext.Request.HttpMethod.ToUpper() == "GET")
                    {
                        // get page title
                        var pageTitle = string.IsNullOrEmpty(filterContext.Controller.ViewBag.Title) ? 
                           "PAGE" : filterContext.Controller.ViewBag.Title;
 
                        // get url
                        string url = filterContext.HttpContext.Request.RawUrl;
 
                        // if current page is not in both queue, then add it
                        if (!crumbTrailQueue.Any(x => x.Value == url && x.Key == pageTitle))
                        {
                            // remove oldest menu item, keep queue length to 6
                            if (crumbTrailQueue.Count >= 5)
                                crumbTrailQueue.Dequeue();
 
                            // insert new menu item into queue
                            crumbTrailQueue.Enqueue(new CrumbTrailKeyValuePair<string, string>(pageTitle, url));
                        }
 
                        crumbTrailCookie.Value = serializer.Serialize(crumbTrailQueue);
                        crumbTrailCookie.Expires = DateTime.Now.AddDays(365);
                        crumbTrailCookie.Path = "/";
                        filterContext.HttpContext.Response.Cookies.Add(crumbTrailCookie);
                    }
 
                    // put in viewbag
                    if (filterContext.Result.GetType().Name == "ViewResult")
                    {
                        (filterContext.Result as ViewResult).ViewBag.QuickAccessQueue = crumbTrailQueue;
                    }
                }
            }
        }
    }
Inside the view, just get the queue from the ViewBag and display accordingly. The queue is stored temporarily in a cookie, so it will be remembered even when the browser is closed and reopen and relogin.
-- read more and comment ...