Calling .NET MVC 3 action method via jQuery ajax and handling serverside redirect
Recently I developed a .NET MVC 3 application where jQuery would be a core part of the design and it was accepted that we did not have to worry any special optimization for phone or mobile devices or disabling of javascript.
During development of this application I wanted to make use of page redirects while using the inherited .NET Controller RedirectToAction()
method. This worked well. When making HttpPost or HttpGet requests I would do the standard re-direct as necessary and everything worked as I would expect.
However, as mentioned the majority of our requests were via jQuery ajax. And not only that, the responses to the ajax request would vary between no data, html data or even possibly a json result. On top of that sometimes at the end of the post request I want to display a popup div that indicates to the user the result of the post.
I found when using the RedirectToAction()
method the details coming back to the jQuery success method was not what I was expecting. What seemed to be occurring was the page was being redirected and the result of that redirect was being returned and then sometimes displayed in the on page div…. duh, of course this was happening, but it’s not what I want!
What instead I wanted to was:
- Continue to have the flexibility to return the RedirectToAction(), a PartialView or even a json object from my controllers when it’s a ajax request and not have to worry about if the server wants a redirect.
- I wanted the ajax requests to come back to the client before any redirect. I would be happy to perform a redirect on the client if required. However as it’s to be a redirect I would prefer to not have to worry about that every time I made a call.
So based on all this I produced some server side code that might have varying results and also varying degrees of accessibility:
[HttpAjax] // I'll get to what this is below [HttpPost] [Authorize] public ActionResult SaveMyResult(MyObject newObject) { if(!ModelState.IsValid) return RedirectToAction("MyResult"); // do stuff with the object return RedirectToAction("MyResults"); } [HttpAjax] [HttpGet] public ActionResult GetUserDetails(int id) { var user = GetUserDetails(id); if(user == null) { return RedirectToAction("NoUser"); } return View(user); } [HttpAjax] [HttpGet] public ActionResult GetUserEvents(int id) { var user = GetUserDetails(id); if(user == null) { return RedirectToAction("NoUser"); } return new JsonResult(user); }
* Please note these are all examples of what I might want to do not real code.
So each of these examples would all be called via Ajax but each would potentially return different results and in turn might cause a page redirect. I didn’t want to have to worry about this from the client side as if the server decides a page is to be redirected I just want it to happen.
Making it all happen
To make this work it ended up being simple but did involve a wee bit of plumbing in both the serverside and the clientside.
- I wanted to identify actions that were only to be called via Ajax. Hence I created a new HttpAjaxAttribute attribute that I could use to decorate my actions much like HttpPost, HttpGet etc
// Code based on: http://helios.ca/2009/05/27/aspnet-mvc-action-filter-ajax-only-attribute/ public class HttpAjaxAttribute : ActionMethodSelectorAttribute { public override bool IsValidForRequest(ControllerContext controllerContext, System.Reflection.MethodInfo methodInfo) { if (controllerContext.HttpContext.Request.IsAjaxRequest()) return true; else throw new ResourceNotFoundException(); } }
NOTE: I’m using NewtonSoft.Json here which can be easily added to your project using NuGet
////// This class is used in conjunction with the HttpAjaxAttribute in order to provide a safe /// and convienent solution for enabling redirects when the action is called using Ajax. It does /// this by hijacking the Result and returning a json object instead with the URL to be redirected to /// public class HttpAjaxRedirectFilter : IActionFilter { public void OnActionExecuting(ActionExecutingContext filterContext) { var modelState = filterContext.Controller.ViewData.ModelState; if (!modelState.IsValid && IsPost(filterContext.RequestContext.HttpContext)) { // If the model state is not valid for ajax requests then we don't even want to carry on with the action. In theory all of our forms // should be using clientside validation so it should all be caught there so any posts that make it through are via some other mechanism filterContext.HttpContext.Response.StatusCode = 400; filterContext.Result = NewtonSoftJson(new { Message = "The data supplied was invalid", Data = modelState }); } } public void OnActionExecuted(ActionExecutedContext filterContext) { var redirectResult = filterContext.Result as RedirectToRouteResult; if (redirectResult != null && !filterContext.Canceled) { var values = redirectResult.RouteValues.Values.ToList(); string actionName = values[0] as string; string controllerName = values[1] as string; UrlHelper urlHelper = new UrlHelper(filterContext.HttpContext.Request.RequestContext); filterContext.Result = JsonRedirectToAction(urlHelper, actionName, controllerName, redirectResult.RouteValues); } } protected bool IsPost(HttpContextBase httpContext) { return httpContext.Request.HttpMethod == "POST"; } protected JsonResult NewtonSoftJson(object obj, JsonRequestBehavior behaviour = JsonRequestBehavior.AllowGet) { return new NetwonSoftJsonActionResult { Data = obj, JsonRequestBehavior = behaviour }; } protected JsonResult JsonRedirectToAction(UrlHelper urlHelper, string actionName, string controllerName, RouteValueDictionary routeValues) { return NewtonSoftJson(new { RedirectUrl = urlHelper.Action(actionName, controllerName, routeValues) }); } }
var myApp = myApp || {}; myApp.core = { redirect: function (url) { location.replace(url); } }); $.extend({ // $.ajaxWithRedirect // // Provides a transparent implemention of the jQuery $.ajax method with the additional alternative of being // able to automatically redirect when a Json object is returned with the RedirectUrl parameter. // // If the caller wants to perform or cancel the redirect they can provide a beforeRedirect function // and return false. ajaxWithRedirect: function (options) { "use strict"; var deferred = $.Deferred(), successHandler, errorHandler, xhr; // force no-cache options.cache = false; // get a copy of the success handler... successHandler = options.success; errorHandler = options.error; options.error = function (data) { if (data.status == 408) { // NOTE: There has to be a better way of specifying the redirectURL. Would be great // if we could get this from the server somehow??? var redirectUrl = "/Account/LogOn"; myApp.core.redirect(redirectUrl); return; } if (typeof errorHandler === 'function') { errorHandler.apply(null, args); deferred.resolve.apply(null, args); } }; // ... and replace it with this one options.success = function (data) { var contentType = xhr.getResponseHeader("Content-Type"), performRedirect = true, redirectUrl = null, args = [].slice.call(arguments, 0), json; // If response isn't there or isn't json, // skip all the redirect logic if (data && (/json/i).test(contentType)) { // If json was requested, and json received, jQuery will // have parsed it already. Otherwise, we'll have to do it if (options.dataType === 'json') { redirectUrl = data.RedirectUrl; } else { try { json = $.parseJSON(data); redirectUrl = json.RedirectUrl; } catch (e) { // not json } } // check the redirect url if (redirectUrl && typeof redirectUrl === 'string') { // Is there a beforeRedirect handler? if (typeof options.beforeRedirect === 'function') { // pass all the arguments to the beforeRedirect handler performRedirect = options.beforeRedirect.apply(null, args); } // unless strictly false, go ahead with the redirect if (performRedirect !== false) { myApp.core.redirect(redirectUrl); // and stop here. No success and/or deferred handlers // will be called since we're redirecting anyway return; } } } // no redirect; forward everything to the success handler(s) if (typeof successHandler === 'function') { successHandler.apply(null, args); deferred.resolve.apply(null, args); } }; // Handling when the user is currently not authorized but they have tried to implement an ajax related action options.statusCode = { 401: function (data) { var redirectUrl = (data && data.responseText && data.responseText.length > 0) ? data.responseText : "/"; myApp.core.redirect(redirectUrl); }, }; // Make the request xhr = $.ajax(options); // Forward the deferred promise method(s) xhr.fail(deferred.reject); xhr.progress(deferred.notify); // Replace the ones already on the xhr obj deferred.promise(xhr); return xhr; } });
HttpAjaxRedirectFilter
filter was encorporated into the application I decided to use Ninject as a DI container. This proved decidedly easy and was simply a matter of making a binding in the App_start NinjectModule.
public class MyTestNJmodule : Ninject.Modules.NinjectModule { public override void Load() { // Filter binding implemented using the NinJect MVC3 extension // http://www.planetgeek.ch/2010/11/13/official-ninject-mvc-extension-gets-support-for-mvc3/#more-2004 this.BindFilter(FilterScope.Action, 1).WhenActionMethodHas (); } }
Making the call in practice
Using our action methods defined above all we need to do is make our ajax call as such.
$.ajaxWithRedirect({ url: '/User/GetUserDetails?id=' + userId, beforeRedirect: function (data) { // do something here before the redirect happens }, success: function (html) { // Put the html into my div here }, complete: function (result) { // do something if we want on complete. // Note the object might be json or html dependant on what we are expecting } });
Note, we do not have to worry about any redirect happening because that is handled in the ajaxWithRedirect itself. Alternatively if we were expecting json back the success: function(html) above would actually contain a json object already serialized for us to use. No need to do anything more.
I hope this helps someone out. It is something that was very handy in the project I was involved in and got used and extended extensively throughout.