ASP.NET MVC 304 Not Modified Filter for Syndication Content

26
Jul
2009

ASP.NET MVC 304 Not Modified Filter for Syndication Content

Earlier I posted about a compression filter for ASP.NET MVC that was the product of two other posts on compression and QValues. I’ve also seen a couple of posts describing different approaches to creating syndication content via ASP.NET MVC – RSS or Atom feeds. Here are two suggestions – one at DeveloperZen and the other at Stack Overflow that both use an RssActionResult subclass of ActionResult. What was missing was a 304 Not Modified filter – so that feeds that have not changed can return a 304 Not Modified HTTP Response - saving bandwidth and improving performance of the consumer. Here’s my attempt at putting it all together using a custom NotModifiedFilter, the CompressionFilter and a SyndicationActionResult class. First the NotModifiedFilter. This article at the Embarcadero Developer Network was very helpful (along with the W3C standard for 304 Not Modified). Here’s the filter.

public class NotModifiedFilterAttribute : ActionFilterAttribute
{
    public override void OnResultExecuted(ResultExecutedContext filterContext)
    {
        var response = filterContext.HttpContext.Response;
        var request = filterContext.HttpContext.Request;           
 
        if ((IsSourceModified(request, response) == false))
        {
            response.SuppressContent = true;
            response.StatusCode = 304;
            response.StatusDescription = "Not Modified";
            // Explicitly set the Content-Length header so the client doesn't wait for
            // content but keeps the connection open for other requests
            response.AddHeader("Content-Length", "0");
        }
    }
}

And here’s the helper method that tests for modified content.

private static bool IsSourceModified(HttpRequestBase request, HttpResponseBase response)
{
    bool dateModified = false;
    bool eTagModified = false;
 
    string requestETagHeader = request.Headers["If-None-Match"] ?? string.Empty;
    string requestIfModifiedSinceHeader = request.Headers["If-Modified-Since"] ?? string.Empty;
    DateTime requestIfModifiedSince;
    DateTime.TryParse(requestIfModifiedSinceHeader, out requestIfModifiedSince);
 
    string responseETagHeader = response.Headers["ETag"] ?? string.Empty;
    string responseLastModifiedHeader = response.Headers["Last-Modified"] ?? string.Empty;
    DateTime responseLastModified;
    DateTime.TryParse(responseLastModifiedHeader, out responseLastModified);
 
    if (requestIfModifiedSince != DateTime.MinValue && responseLastModified != DateTime.MinValue)
    {
        if (responseLastModified > requestIfModifiedSince)
        {
            TimeSpan diff = responseLastModified - requestIfModifiedSince;
            if (diff > TimeSpan.FromSeconds(1))
            {
                dateModified = true;
            }
        }
    }
    else
    {
        dateModified = true;
    }
 
    //Leave the default for eTagModified = false so that if we
    //don't get an ETag from the server we will rely on the fileDateModified only
    if (String.IsNullOrEmpty(responseETagHeader) == false)
    {
        eTagModified = responseETagHeader.Equals(requestETagHeader, StringComparison.Ordinal) == false;
    }
 
    return (dateModified || eTagModified);
}

And lastly – here’s my SyndicationActionResult class which uses a Func<FeedData> delegate to get the feed data – and so can be used for any type of feed – Rss, Atom, Sitemaps etc. (FeedData contains the feed content as well as the last modified date and the ETag). Not sure if I’m guilty of cargo cult programming here since I was a little unsure about setting the ‘Last-Modified’ header manually instead of using response.Cache.SetLastModified() method. However the latter was not showing up in the HttpContext of the OnResultExecuted method in the filter. Maybe the runtime moves this value into the headers collection later in the pipeline.

public class SyndicationActionResult : ActionResult
{
    public Func&lt;FeedData&gt; ActionDelegate { get; set; }
 
    public override void ExecuteResult(ControllerContext context)
    {
        if (ActionDelegate != null)
        {
            var response = context.HttpContext.Response;
            var data = ActionDelegate.Invoke() as FeedData;
            if (data != null)
            {
                response.ContentType = data.ContentType;
                response.AppendHeader("Cache-Control", "private");
                response.AppendHeader("Last-Modified", data.LastModifiedDate.ToString("r"));
                //response.Cache.SetLastModified(data.LastModifiedDate);
                response.AppendHeader("ETag", String.Format("\"{0}\"", data.ETag));
                response.Output.WriteLine(data.Content);
                response.StatusCode = 200;
                response.StatusDescription = "OK";
            }
        }
    }
}

Note that we write the content to the response.Output stream - however if the NotModifiedFilter tells us nothing has changed – then this will be suppressed – (although it is important that the ETag remains). And very lastly - here's the Action in my SyndicationController class that puts it all together, and in this case – is used to provide the aggregate Atom feed on my home page at http://www.58bits.com.

[AcceptVerbs(HttpVerbs.Get)]
[NotModifiedFilter(Order = 1)]
[CompressFilter(Order = 2)]
public SyndicationActionResult SiteFeedAtom()
{
    return new SyndicationActionResult() { ActionDelegate = SyndicationHelper.GetAggregateAtomFeed };
}

As Scott sometimes says -  it may be  “poo”… or it maybe useful. At least it seems to be working ok for me, although suggestions on how any of it might be improved would be greatly appreciated.

Category: 

Comments

Great post! But please, could you post the source for FeedData object? Thanks MarcelloP

Hi Marcello. Here's the code for the FeedData class... public class FeedData { public string Key { get; set; } public string ContentType { get; set; } public string Content { get; set; } public DateTime LastModifiedDate { get; set; } public string ETag { get; set; } }

Thank you Anthony, but one thing is'nt clear for me: how I do set the ETag property of the FeedData object? What should be its value? Thanks, MarcelloP

Hi again Marcello. You create the ETag yourself - as long as it is unique to the resource. For some items like a feed - you can create an MD5 hash of the feed, and use this - since it will change as the feed changes (which means it needs to be updated and shouldn't return a 304 response).

Hi there, i'm interested in creating just such a caching solution and have tried to implement this action filter attribute. however i am getting an error whereby a PlatformNotSupportedException is thrown when i attempt to access the HTTP Response headers collection. What would be the recommended approach for this?

Hi - unfortunately a statement like response.Headers["Last-Modified"] will only work using IIS7 in integrated pipeline mode. In classic mode or on IIS6 you can't read response.Headers - you can only write to them using response.AppendHeader("header-name", "header-value"). So you'll need to pass the parameters you need into whatever method you are using to do the comparison between the request header values - and the values that will tell you whether the resource has been modified or not. Hope this helps....

What is the .Key property used for in the FeedData class? Thanks, Matthew

I use he Key property to set a string value - that uniquely identifies this feed - like "sitemmap", "atomfeed", "newsatomfeed" - or whatever - and then use this for Caching purposes - so that I can cache the feed content depending on the refresh rate required - like this... Cache.Add(data.Key, data, null, DateTime.Now.AddDays(1), Cache.NoSlidingExpiration, CacheItemPriority.Normal, SiteMapacheItemRemovedCallBack);