How to serve the same data in Json, Xml or Html with Asp.Net Mvc revised
After I had blogged about my first example I talked with Eric Hexter (from the mvccontrib team) and Christian Dalager. Christian pointed out that there is an issue with the paths that doesn’t act in the same way as it does in Rails.
FormatController did something like this, when handling parameters, “/home/view.xml/4” when it should have done “/home/view/4.xml”.
Another thing that also needed to change was the requirement to inherit from the base class before it would work.
Now you can decorate your controller or action with an attribute that will handle how your data will be rendered. It is also possible to disallow one or more of the formats.
I have of course submitted it to MvcContrib, but I don’t know if it will or when it will make it into the project. So, I hope that someone can benefit from my post in the meantime.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false, AllowMultiple = false)] public sealed class FormatFilterAttribute : FilterAttribute, IActionFilter { /// <summary> /// The type of files we can server the requested content in /// </summary> private enum FileFormat { Html, Json, Xml } public FormatFilterAttribute() { Disallow = string.Empty; RequestedFormat = FileFormat.Html; } /// <summary> /// Formats to disallow can be either Html, Json or Xml. Use comma to seperate multiple formats. /// </summary> public string Disallow { get; set; } /// <summary> /// The format that has been requested /// </summary> private FileFormat RequestedFormat { get; set; } /// <summary> /// Occurs before an action is executed /// </summary> /// <param name="filterContext"></param> public void OnActionExecuting(ActionExecutingContext filterContext) { FileFormat format = GetFileFormat(filterContext.HttpContext.Request.Path); if(IsDisallowed(format)) { throw new ArgumentException("Requested format has been disallowed"); } RequestedFormat = format; } /// <summary> /// Occurs after an action is executed /// </summary> /// <param name="filterContext"></param> public void OnActionExecuted(ActionExecutedContext filterContext) { if(!(filterContext.Result is ViewResult)) { throw new InvalidOperationException("You need to call the View method, when the FormatFilter attribute is applied"); } var view = (ViewResult)(filterContext.Result); filterContext.Result = FormatViewResult(view); } /// <summary> /// Verifies if the format has been disallowed /// </summary> /// <param name="format"></param> /// <returns></returns> private bool IsDisallowed(FileFormat format) { return Disallow.Split(',').Any(s => s.ToLower() == format.ToString().ToLower()); } /// <summary> /// Verifies that the requested format is one that can be servered /// </summary> /// <param name="ext"></param> /// <returns></returns> private bool IsValidFileExtension(string ext) { return Enum.GetNames(typeof(FileFormat)).Any(format => format.ToLower() == ext.Substring(1).ToLower()); } /// <summary> /// /// </summary> /// <param name="path"></param> /// <returns></returns> private FileFormat GetFileFormat(string path) { string ext = Path.GetExtension(path); if(string.IsNullOrEmpty(ext)) { return FileFormat.Html; } if (!IsValidFileExtension(ext)) { throw new FormatException("Requested format is not available"); } return (FileFormat)Enum.Parse(typeof(FileFormat), ext.Substring(1), true); } private ActionResult FormatViewResult(ViewResultBase view) { switch (RequestedFormat) { case FileFormat.Html: return view; case FileFormat.Json: return new JsonResult { Data = view.ViewData.Model }; case FileFormat.Xml: return new XmlResult(view.ViewData.Model); default: throw new FormatException(string.Concat("Cannot server the content in the request format: ", RequestedFormat)); } } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | [FormatFilter] public class HomeController : Controller { public ActionResult Index() { var pizzas = new[] { new Pizza {Number = 1, Name = "Pizza 1", Price = "$10"}, new Pizza {Number = 2, Name = "Pizza 2", Price = "$13"}, new Pizza {Number = 3, Name = "Pizza 3", Price = "$20"} }; return View(pizzas); } } |
Working with parameters
If you are going send data, like an ID, to your Action then you need to add an extra route or else it might not get parse correctly.
1 2 3 4 5 6 | // Add this to Global.asax routes.MapRoute( "Format", ""{controller}/{action}/{id}.{format}"", new {id = "", format = ""} ); |
looks good. I know we are running pretty slow on evaluating patches for mvccontrib.. it usually happens on weekend nights.
Thanks :)
[...] How to serve the same data in Json, Xml or Html with Asp.Net Mvc revised - Mark Jensen shares some code to allow you to easily return data in a number of different formats in ASP.NET MVC (rather like the way rails does). This code is destined for the mvccontrib project, so expect to see it used in lots of places. [...]
Very nice - we have implemented similar functionality, but not as cleanly. Basically we use a format in the query string instead of an extension, but now that I see your code… ;-)
One thing we find we use a lot is a “partial” option. This allows our client side code to call back for rendered HTML for just a part of the page. Something you may want to consider.
Cheers,
Dave
I really like this idea, but when I started playing with this I ran into a snag that’s worth noting…
I setup an Order method in the home controller, and gave it this signature:
public ActionResult Order(int id)
{
//Stuff returning a view w/ data
}
I then hit http://foo/home/order/23.json
The problem is that when the route is parsed, it interprets 23.json as a string input type for the id argument, and throws an exception. doh!
If I change the argument type to string, it works, except now I have to strip the extension, and cast to an int so my repository can fetch the correct data.
From what I can see of ActionFilters, I don’t think we can strip the extension off before the action is executed.
Anyhow - I’m going to stick with our current methodology using query string parameters (&f=json)
Also - your code uses an extension method on Array (Array.Any) - it would be handy if you could reference where that comes from and/or add it to http://www.extensionmethod.net
Cheers,
Dave
Doh! My bad on the .Any thing - I did not have a using System.Linq!
Dave
Great work! I like specifying the extension in the url. Other methods I have seen require setting the content type of the request.
I didnt realise at first I needed to add the route from your first post. After I did this it worked beautifully.
Hi Dave
Thank you for pointing that out - I know that it might seem like a problem, but you can do two things…
1. Do like Joel and add a route ex. “{controller}/{action}/{id}.{format}”
or
2. Make your own ModelBinder - Haven’t tested this, but I guess it could work.