Handling Formats Based On Url Extension

asp.net, asp.net mvc 0 comments suggest edit

Rob pinged me today asking about how to respond to requests using different formats based on the extension in the URL. More specifically, he’d like to respond with HTML if there is no file extension, but with JSON if the URL ended with .json etc…

/home/index –> HTML

/home/index.json –> JSON

The first thing I wanted to tackle was writing a custom action invoker that would decide based on what’s in the route data, how to format the response.

This would allow the developer to simply return an object (the model) from an action method and the invoker would look for the format in the route data and figure out what format to send.

So I wrote a custom action invoker:

public class FormatHandlingActionInvoker : ControllerActionInvoker {
  protected override ActionResult CreateActionResult(
      ControllerContext controllerContext, 
      ActionDescriptor actionDescriptor, 
      object actionReturnValue) {
    if (actionReturnValue == null) {
      return new EmptyResult();
    }

    ActionResult actionResult = actionReturnValue as ActionResult;
    if (actionResult != null) {
      return actionResult;
    }

    string format = (controllerContext.RouteData.Values["format"] 
        ?? "") as string;
      switch (format) {
        case "":
        case "html":
          var result = new ViewResult { 
            ViewData = controllerContext.Controller.ViewData, 
            ViewName = actionDescriptor.ActionName 
          };
          result.ViewData.Model = actionReturnValue;
          return result;
          
        case "rss":
          //TODO: RSS Result
          break;
        case "json":
          return new JsonResult { Data = actionReturnValue };
    }

    return new ContentResult { 
      Content = Convert.ToString(actionReturnValue, 
       CultureInfo.InvariantCulture) 
    };
  }
}

The key thing to note is that I overrode the method CreateActionResult. This method is responsible for examining the result returned from an action method (which can be any type) and figuring out what to do with it. In this case, if the result is already an ActionResult, we just use it. However, if it’s something else, we look at the format in the route data to figure out what to return.

For reference, here’s the code for the HomeController which simply returns an object.

[HandleError]
public class HomeController : Controller {
  public object Index() {
    return new {Title = "HomePage", Message = "Welcome to ASP.NET MVC" };
  }
}

In order to make sure that all my controllers replaced the default invoker with this invoker, I wrote a controller factory that would set this invoker. I won’t show the code here, but I will include it in the download.

So at this point, we have everything in place, except for the fact that I haven’t dealt with how we get the format in the route data in the first place. Unfortunately, it ends up that this isn’t quite so straightforward. Consider the default route:

routes.MapRoute(
    "Default",
    "{controller}/{action}/{id}",
    new { controller = "Home", action = "Index", id = "" }
);

Since this route allows for default values at each segment, this single route matches all the following URLs:

  • /home/index/123
  • /home/index
  • /home

So the question becomes, if we want to support optional format extensions in the URL, would we have to support it for every segment? Making up a fictional syntax, maybe it would look like this:

routes.MapRoute(
    "Default",
    "{controller{.format}}/{action{.format}}/{id{.format}}",
    new { controller = "Home", action = "Index", id = "", format = ""}
);

Where the {.format} part would be optional. Of course, we don’t have such a syntax available, so I needed to put on my dirty ugly hacking hat and see what I could come up with. I decided to do something we strongly warn people not to do, inheriting HttpRequestWrapper with my own HttpRequestBase implementation and stripping off the extension before I try and match the routes.

Warning! Don’t do this at home! This is merely experimentation while I mull over a better approach. This approach relies on implementation details I should not be relying upon

public class HttpRequestWithFormat : HttpRequestWrapper
{
  public HttpRequestWithFormat(HttpRequest request) : base(request) { 
  }

  public override string AppRelativeCurrentExecutionFilePath {
    get
    {
      string filePath = base.AppRelativeCurrentExecutionFilePath;
      string extension = System.IO.Path.GetExtension(filePath);
      if (String.IsNullOrEmpty(extension)) {
        return filePath;
      }
      else {
        Format = extension.Substring(1);
        filePath = filePath.Substring(0, filePath.LastIndexOf(extension));
      }
      return filePath;
    }
  }

  public string Format {
    get;
    private set;
  }
}

I also had to write a custom route.

public class FormatRoute : Route
{
  public FormatRoute(Route route) : 
    base(route.Url + ".{format}", route.RouteHandler) {
    _originalRoute = route;
  }

  public override RouteData GetRouteData(HttpContextBase httpContext)
  {
    //HACK! 
    var context = new HttpContextWithFormat(HttpContext.Current);

    var routeData = _originalRoute.GetRouteData(context);
    var request = context.Request as HttpRequestWithFormat;
    if (!string.IsNullOrEmpty(request.Format)) {
      routeData.Values.Add("format", request.Format);
    }

    return routeData;
  }

  public override VirtualPathData GetVirtualPath(
    RequestContext requestContext, RouteValueDictionary values)
  {
    var vpd = base.GetVirtualPath(requestContext, values);
    if (vpd == null) {
      return _originalRoute.GetVirtualPath(requestContext, values);
    }
    
    // Hack! Let's fix up the URL. Since "id" can be empty string,  
    // we want to check for this condition.
    string format = values["format"] as string;
    string funkyEnding = "/." + format as string;
    
    if (vpd.VirtualPath.EndsWith(funkyEnding)) { 
      string virtualPath = vpd.VirtualPath;
      int lastIndex = virtualPath.LastIndexOf(funkyEnding);
      virtualPath = virtualPath.Substring(0, lastIndex) + "." + format;
      vpd.VirtualPath = virtualPath;
    }

    return vpd;
  }

  private Route _originalRoute;
}

When matching incoming requests, this route replaces the HttpContextBase with my faked up one before calling GetRouteData. My faked up context returns a faked up request which strips the format extension from the URL.

Also, when generating URLs, this route deals with the cosmetic issue that the last segment of the URL has a default value of empty string. This makes it so that the URL might end up looking like /home/index/.jsonwhen I really wanted it to look like /home/index.json.

I’ve omitted some code from this blog post, but you can download the project here and try it out. Just navigate to /home/index and then try /home/index.json and you should notice the response format changes.

This is just experimental work. There’d be much more to do to make this useful. For example, would be nice if an action could specify which formats it would respond to. Likewise, it might be nice to respond based on accept headers rather than formats. I just wanted to see how automatic I could make it.

In any case, I was just having fun and didn’t have much time to put this together. The takeaway from this is really the CreateActionResult method of ControllerActionInvoker. That makes it very easy to create interesting default behavior so that your action methods can return whatever they want and you can implement your own conventions by overriding that method.

Download the hacky experiment here**and play with it at your own risk. :)

Found a typo or error? Suggest an edit! If accepted, your contribution is listed automatically here.

Comments

avatar

20 responses

  1. Avatar for Chad Moran
    Chad Moran January 6th, 2009

    This is another great example of how extensible ASP.NET MVC is. Keep up the good work.

  2. Avatar for Aleš Roubíček
    Aleš Roubíček January 6th, 2009

    IMO better solution is using Accept-Type header in you request to specify result format. :) It's less tricky and more HTTP way...

  3. Avatar for Robert Mircea
    Robert Mircea January 6th, 2009

    Any idea or pointer on how to do the same thing with WCF RESTful webservices?

  4. Avatar for josh
    josh January 6th, 2009

    I dont know if you've looked at rails, but I know Rob has. That's probably where his suggestions is coming from. In rails, you don't need to do anything special other than register the type in environment.rb. The just use responds_to in the controller.
    info.michael-simons.eu/.../rails-respond_to-method

  5. Avatar for Troy Goode
    Troy Goode January 6th, 2009

    I've thought about doing something similar before, but always get hung up on the potential security issues. Typically the ViewData that we send down to a view is safe, in that only what we decide to expose in the view itself is deliver to the client. By automatically piping the viewdata for all actions out to JSON, you could easily leave yourself vulnerable to data you would otherwise not want to display.
    One option is to change the invoker to only support a format if the requested action is tagged with a matching format attribute, e.g.:
    [AcceptFormats(ResponseFormats.Json)]
    public object Index(){ ... }
    Thoughts?

  6. Avatar for Mike Amundsen
    Mike Amundsen January 6th, 2009

    I agree w/ Aleš; Accept-Type is the best way to handle this. But supporting *both* is fine, too. I also second Robert's comment. Adding this kind of 'representation-awareness' to WCF is critical.

  7. Avatar for Nathan LeMesurier
    Nathan LeMesurier January 6th, 2009

    I've been working with ASP.NET for a long time, but Rail's built-in RESTful routing and format response is something I've wanted for a long time. It would make a nice addition to MVC out of the box, instead of something that we have to add on.

  8. Avatar for haacked
    haacked January 6th, 2009

    @josh Yep, I have worked with Rails and am familiar with its method of respond_to. This was just experimentation with alternative approaches. I think Troy has a great point in that you really would want to specify which formats an action method accepts. I just haven't gotten to that yet.
    I want to see what's more natural, using an attribute or doing it in code? For example, Hammet showed me a prototype of another method of doing this which involved lambdas a long time ago that looked pretty cool. I'll have to ping him about digging that up and updating it.

  9. Avatar for haacked
    haacked January 6th, 2009

    Keep in mind this post is just me experimenting with some ideas in the open. This is not going in v1, but based on the result of these experiments, something much better (I hope!) could end up in vNext.

  10. Avatar for Troy Goode
    Troy Goode January 7th, 2009

    @Phil: Just to throw my $.02 back in... the reason I went with an attribute in my above example is that this really seems similar to the [AcceptVerbs] concept, so I was proposing a similar solution. I'd be interested in seeing Hammet's lambda example though, if you're able to find it.

  11. Avatar for ColinM
    ColinM January 7th, 2009

    I'm wondering if using the url rewrite module with asp.net MVC would be an option?
    learn.iis.net/.../using-url-rewrite-module/

  12. Avatar for haacked
    haacked January 7th, 2009

    @ColinM that's not a bad idea for matching incoming URLs, it's generating outgoing that I worry about.

  13. Avatar for Billy
    Billy January 7th, 2009

    This may be a stupid question, but if using the Accept header, how do you SET that? All of my requests (regardless of extension) return "*/*". Is there a way to map extensions to Accept headers built in to IIS or do I need a custom module to intercept and set the headers?

  14. Avatar for Troy Goode
    Troy Goode January 7th, 2009

    @Billy: if you're using jQuery and specify a return type of "json", jQuery is smart enough to automatically append an accept-type header of "application/json" out of the box

  15. Avatar for Daniel Chow
    Daniel Chow January 8th, 2009

    Hi Haaked , have you try this :
    routes.MapRoute(
    "Default",
    "{controller}/test.html",
    new { controller = "Home", action = "Test" }
    );
    i mean the routing treat extesion as a common string . so ,Haccked, how do you think about this ?

  16. Avatar for Christiaan VdP
    Christiaan VdP January 8th, 2009

    I've tried the same excercise some time ago and I came to the following solution:
    define the following routes:
    routes.MapRoute("first"
    , "{controller}/{action}/{id}.{type}"
    , new {controller = "Home", action = "Index", id = "", type = "html"});
    routes.MapRoute("second"
    , "{controller}/{action}/{id}"
    , new { controller = "Home", action = "Index", id = "", type = "html" });
    And now I've created a Custom IViewEngine which is actually a factory around the real viewengine. It's this wrapper that will decide which ViewEngineResult it will return, depending on the 'type' value in the RouteData
    class WrapperViewEngine : IViewEngine
    {
    private IViewEngine webformEngine = new WebFormViewEngine();
    #region IViewEngine Members
    public ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName)
    {
    if (controllerContext.RouteData.Values["type"].ToString().ToLower() == "html")
    return defaultEngine.FindView(controllerContext, viewName, masterName);
    else
    return new XmlViewEngineResult(new XmlView(controllerContext.Controller.ViewData), this);
    }
    #endregion
    <snip>
    }
    and this is the XmlView
    public class XmlView : IView
    {
    public XmlView(ViewDataDictionary aDataDictionary)
    {
    }
    public void Render(ViewContext viewContext, TextWriter writer)
    {
    if( viewContext.ViewData.Model != null)
    {
    XmlSerializer serializer = new XmlSerializer(viewContext.ViewData.Model.GetType());
    serializer.Serialize(writer, viewContext.ViewData.Model);
    }
    }
    }
    now you can handle the following routes:
    /controller/action(/id) => type = html
    /controller/action/id.html => type = html
    /controller/action/id.xml => type = xml

  17. Avatar for Chuck Fletcher
    Chuck Fletcher February 16th, 2009

    Is it possible to match Rails urls?
    i.e. /event.xml (vs /event/index.xml)?
    To get a list of my events?
    Thanks,
    Chuck

  18. Avatar for Rogah
    Rogah October 28th, 2010

    Hi Haacked,
    Have you had any progress with this experimental solution? I need to handle urls like "/home/index" for html extensions.
    PS: The download link seems to be broken.
    Thanks in advance.

  19. Avatar for Robert
    Robert December 19th, 2010

    Did this ever materialize into a formal release?

  20. Avatar for Mayrun Digmi
    Mayrun Digmi March 14th, 2012

    it's irrelevant now that ASP.NET MVC 4 includes ASP.NET Web API.