I would like to create an ETag filter in MVC.
The problem is that I can't control the Response.OutputStream, if I was able to do that I would simply calculate the ETag according to the result stream.
I did this thing before in WCF but couldn't find any simple idea to do that in MVC.
I want to be able to write something like that
[ETag]
public ActionResult MyAction()
{
var myModel = Factory.CreateModel();
return View(myModel);
}
Any idea?
This is the best I could come up with, I didn't really understand what you meant by you can't control the Response.OutputStream.
using System;
using System.IO;
using System.Security.Cryptography;
using System.Web.Mvc;
public class ETagAttribute : ActionFilterAttribute
{
private string GetToken(Stream stream) {
MD5 md5 = MD5.Create();
byte [] checksum = md5.ComputeHash(stream);
return Convert.ToBase64String(checksum, 0, checksum.Length);
}
public override void OnResultExecuted(ResultExecutedContext filterContext)
{
filterContext.HttpContext.Response.AppendHeader("ETag", GetToken(filterContext.HttpContext.Response.OutputStream));
base.OnResultExecuted(filterContext);
}
}
This should work, but is doesn't.
Apparently Microsoft overrode System.Web.HttpResponseStream.Read(Byte[] buffer, Int32 offset, Int32 count) so that it returns "Specified method is not supported.", not sure why they would do that, since it inherits for the System.IO.Stream base class...
Which is mix up of the following resources, the Response.OutputStream is a write only stream, so we have to use a Response.Filter class to read the output stream, kind of quirky that you have to use a filter on a filter, but it works =)
http://bytes.com/topic/c-sharp/answers/494721-md5-encryption-question-communication-java
http://www.codeproject.com/KB/files/Calculating_MD5_Checksum.aspx
http://blog.gregbrant.com/post/Adding-Custom-HTTP-Headers-to-an-ASPNET-MVC-Response.aspx
http://www.infoq.com/articles/etags
http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
Update
After much fighting I was finally able to get this to work:
using System;
using System.IO;
using System.Security.Cryptography;
using System.Web;
using System.Web.Mvc;
public class ETagAttribute : ActionFilterAttribute {
public override void OnActionExecuting(ActionExecutingContext filterContext) {
try {
filterContext.HttpContext.Response.Filter = new ETagFilter(filterContext.HttpContext.Response);
} catch (System.Exception) {
// Do Nothing
};
}
}
public class ETagFilter : MemoryStream {
private HttpResponseBase o = null;
private Stream filter = null;
public ETagFilter (HttpResponseBase response) {
o = response;
filter = response.Filter;
}
private string GetToken(Stream stream) {
byte[] checksum = new byte[0];
checksum = MD5.Create().ComputeHash(stream);
return Convert.ToBase64String(checksum, 0, checksum.Length);
}
public override void Write(byte[] buffer, int offset, int count) {
byte[] data = new byte[count];
Buffer.BlockCopy(buffer, offset, data, 0, count);
filter.Write(data, 0, count);
o.AddHeader("ETag", GetToken(new MemoryStream(data)));
}
}
More Resources:
http://authors.aspalliance.com/aspxtreme/sys/Web/HttpResponseClassFilter.aspx
http://forums.asp.net/t/1380989.aspx/1
Thanks a lot it is exactly what I was looking for.
Just made a small fix to the ETagFilter that will handle 304 in case that the content wasn't changed
public class ETagAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
filterContext.HttpContext.Response.Filter = new ETagFilter(filterContext.HttpContext.Response, filterContext.RequestContext.HttpContext.Request);
}
}
public class ETagFilter : MemoryStream
{
private HttpResponseBase _response = null;
private HttpRequestBase _request;
private Stream _filter = null;
public ETagFilter(HttpResponseBase response, HttpRequestBase request)
{
_response = response;
_request = request;
_filter = response.Filter;
}
private string GetToken(Stream stream)
{
byte[] checksum = new byte[0];
checksum = MD5.Create().ComputeHash(stream);
return Convert.ToBase64String(checksum, 0, checksum.Length);
}
public override void Write(byte[] buffer, int offset, int count)
{
byte[] data = new byte[count];
Buffer.BlockCopy(buffer, offset, data, 0, count);
var token = GetToken(new MemoryStream(data));
string clientToken = _request.Headers["If-None-Match"];
if (token != clientToken)
{
_response.Headers["ETag"] = token;
_filter.Write(data, 0, count);
}
else
{
_response.SuppressContent = true;
_response.StatusCode = 304;
_response.StatusDescription = "Not Modified";
_response.Headers["Content-Length"] = "0";
}
}
}
There are quite a few promising answers. But none of them is complete solution. Also it was not part of the question and nobody mentioned it. But ETag should be used for Cache validation. Therefore it should be used with Cache-Control header. So clients don't even have to call the server until the cache expires (it can be very short period of time depends on your resource). When the cache expired then client makes a request with ETag and validate it. For more details about caching see this article.
Here is my CacheControl attribute solution with ETags. It can be improved e.g. with Public cache enabled, etc... However I strongly advise you to understand caching and modify it carefully. If you use HTTPS and the endpoints are secured then this setup should be fine.
/// <summary>
/// Enables HTTP Response CacheControl management with ETag values.
/// </summary>
public class ClientCacheWithEtagAttribute : ActionFilterAttribute
{
private readonly TimeSpan _clientCache;
private readonly HttpMethod[] _supportedRequestMethods = {
HttpMethod.Get,
HttpMethod.Head
};
/// <summary>
/// Default constructor
/// </summary>
/// <param name="clientCacheInSeconds">Indicates for how long the client should cache the response. The value is in seconds</param>
public ClientCacheWithEtagAttribute(int clientCacheInSeconds)
{
_clientCache = TimeSpan.FromSeconds(clientCacheInSeconds);
}
public override async Task OnActionExecutedAsync(HttpActionExecutedContext actionExecutedContext, CancellationToken cancellationToken)
{
if (!_supportedRequestMethods.Contains(actionExecutedContext.Request.Method))
{
return;
}
if (actionExecutedContext.Response?.Content == null)
{
return;
}
var body = await actionExecutedContext.Response.Content.ReadAsStringAsync();
if (body == null)
{
return;
}
var computedEntityTag = GetETag(Encoding.UTF8.GetBytes(body));
if (actionExecutedContext.Request.Headers.IfNoneMatch.Any()
&& actionExecutedContext.Request.Headers.IfNoneMatch.First().Tag.Trim('"').Equals(computedEntityTag, StringComparison.InvariantCultureIgnoreCase))
{
actionExecutedContext.Response.StatusCode = HttpStatusCode.NotModified;
actionExecutedContext.Response.Content = null;
}
var cacheControlHeader = new CacheControlHeaderValue
{
Private = true,
MaxAge = _clientCache
};
actionExecutedContext.Response.Headers.ETag = new EntityTagHeaderValue($"\"{computedEntityTag}\"", false);
actionExecutedContext.Response.Headers.CacheControl = cacheControlHeader;
}
private static string GetETag(byte[] contentBytes)
{
using (var md5 = MD5.Create())
{
var hash = md5.ComputeHash(contentBytes);
string hex = BitConverter.ToString(hash);
return hex.Replace("-", "");
}
}
}
Usage e.g: with 1 min client side caching:
[ClientCacheWithEtag(60)]
this is the code i created to solve this problem - i inherit from gzip because i want to gzip the stream as well ( you can always use a regular stream)
the difference is that i calculate the etag for all my response and not just chunk of it.
public class ETagFilter : GZipStream
{
private readonly HttpResponseBase m_Response;
private readonly HttpRequestBase m_Request;
private readonly MD5 m_Md5;
private bool m_FinalBlock;
public ETagFilter(HttpResponseBase response, HttpRequestBase request)
: base(response.Filter, CompressionMode.Compress)
{
m_Response = response;
m_Request = request;
m_Md5 = MD5.Create();
}
protected override void Dispose(bool disposing)
{
m_Md5.Dispose();
base.Dispose(disposing);
}
private string ByteArrayToString(byte[] arrInput)
{
var output = new StringBuilder(arrInput.Length);
for (var i = 0; i < arrInput.Length; i++)
{
output.Append(arrInput[i].ToString("X2"));
}
return output.ToString();
}
public override void Write(byte[] buffer, int offset, int count)
{
m_Md5.TransformBlock(buffer, 0, buffer.Length, null, 0);
base.Write(buffer, 0, buffer.Length);
}
public override void Flush()
{
if (m_FinalBlock)
{
base.Flush();
return;
}
m_FinalBlock = true;
m_Md5.TransformFinalBlock(new byte[0], 0, 0);
var token = ByteArrayToString(m_Md5.Hash);
string clientToken = m_Request.Headers["If-None-Match"];
if (token != clientToken)
{
m_Response.Headers["ETag"] = token;
}
else
{
m_Response.SuppressContent = true;
m_Response.StatusCode = 304;
m_Response.StatusDescription = "Not Modified";
m_Response.Headers["Content-Length"] = "0";
}
base.Flush();
}
}