ASP.NET MVC 304 Not Modified Filter for Syndication Content

Submitted on Jul 25, 2009, 11:35 p.m.

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.

1public class NotModifiedFilterAttribute : ActionFilterAttribute
2{
3 public override void OnResultExecuted(ResultExecutedContext filterContext)
4 {
5 var response = filterContext.HttpContext.Response;
6 var request = filterContext.HttpContext.Request;
7
8 if ((IsSourceModified(request, response) == false))
9 {
10 response.SuppressContent = true;
11 response.StatusCode = 304;
12 response.StatusDescription = "Not Modified";
13 // Explicitly set the Content-Length header so the client doesn't wait for
14 // content but keeps the connection open for other requests
15 response.AddHeader("Content-Length", "0");
16 }
17 }
18}

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

1private static bool IsSourceModified(HttpRequestBase request, HttpResponseBase response)
2{
3 bool dateModified = false;
4 bool eTagModified = false;
5
6 string requestETagHeader = request.Headers["If-None-Match"] ?? string.Empty;
7 string requestIfModifiedSinceHeader = request.Headers["If-Modified-Since"] ?? string.Empty;
8 DateTime requestIfModifiedSince;
9 DateTime.TryParse(requestIfModifiedSinceHeader, out requestIfModifiedSince);
10
11 string responseETagHeader = response.Headers["ETag"] ?? string.Empty;
12 string responseLastModifiedHeader = response.Headers["Last-Modified"] ?? string.Empty;
13 DateTime responseLastModified;
14 DateTime.TryParse(responseLastModifiedHeader, out responseLastModified);
15
16 if (requestIfModifiedSince != DateTime.MinValue && responseLastModified != DateTime.MinValue)
17 {
18 if (responseLastModified > requestIfModifiedSince)
19 {
20 TimeSpan diff = responseLastModified - requestIfModifiedSince;
21 if (diff > TimeSpan.FromSeconds(1))
22 {
23 dateModified = true;
24 }
25 }
26 }
27 else
28 {
29 dateModified = true;
30 }
31
32 //Leave the default for eTagModified = false so that if we
33 //don't get an ETag from the server we will rely on the fileDateModified only
34 if (String.IsNullOrEmpty(responseETagHeader) == false)
35 {
36 eTagModified = responseETagHeader.Equals(requestETagHeader, StringComparison.Ordinal) == false;
37 }
38
39 return (dateModified || eTagModified);
40}

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.

1public class SyndicationActionResult : ActionResult
2{
3 public Func<FeedData> ActionDelegate { get; set; }
4
5 public override void ExecuteResult(ControllerContext context)
6 {
7 if (ActionDelegate != null)
8 {
9 var response = context.HttpContext.Response;
10 var data = ActionDelegate.Invoke() as FeedData;
11 if (data != null)
12 {
13 response.ContentType = data.ContentType;
14 response.AppendHeader("Cache-Control", "private");
15 response.AppendHeader("Last-Modified", data.LastModifiedDate.ToString("r"));
16 //response.Cache.SetLastModified(data.LastModifiedDate);
17 response.AppendHeader("ETag", String.Format("\"{0}\"", data.ETag));
18 response.Output.WriteLine(data.Content);
19 response.StatusCode = 200;
20 response.StatusDescription = "OK";
21 }
22 }
23 }
24}

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.

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

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.