How to use ETag in Web API using action filter along with HttpResponseMessage
asp.net-web-api, c#
Solution
My requirement was to cache my web api JSON responses... And all the solutions provided don't have an easy "link" to where the data is generated - ie in the Controller...
So my solution was to create a wrapper "CacheableJsonResult" which generated a Response, and then added the ETag to the header. This allows a etag to be passed in when the controller method is generated and wants to return the content...
public class CacheableJsonResult<T> : JsonResult<T>
{
private readonly string _eTag;
private const int MaxAge = 10; //10 seconds between requests so it doesn't even check the eTag!
public CacheableJsonResult(T content, JsonSerializerSettings serializerSettings, Encoding encoding, HttpRequestMessage request, string eTag)
:base(content, serializerSettings, encoding, request)
{
_eTag = eTag;
}
public override Task<HttpResponseMessage> ExecuteAsync(System.Threading.CancellationToken cancellationToken)
{
Task<HttpResponseMessage> response = base.ExecuteAsync(cancellationToken);
return response.ContinueWith<HttpResponseMessage>((prior) =>
{
HttpResponseMessage message = prior.Result;
message.Headers.ETag = new EntityTagHeaderValue(String.Format("\"{0}\"", _eTag));
message.Headers.CacheControl = new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromSeconds(MaxAge)
};
return message;
}, cancellationToken);
}
}
And then, in your controller - return this object:
[HttpGet]
[Route("results/{runId}")]
public async Task<IHttpActionResult> GetRunResults(int runId)
{
//Is the current cache key in our cache?
//Yes - return 304
//No - get data - and update CacheKeys
string tag = GetETag(Request);
string cacheTag = GetCacheTag("GetRunResults"); //you need to implement this map - or use Redis if multiple web servers
if (tag == cacheTag )
return new StatusCodeResult(HttpStatusCode.NotModified, Request);
//Build data, and update Cache...
string newTag = "123"; //however you define this - I have a DB auto-inc ID on my messages
//Call our new CacheableJsonResult - and assign the new cache tag
return new CacheableJsonResult<WebsiteRunResults>(results, GlobalConfiguration.Configuration.Formatters.JsonFormatter.SerializerSettings, System.Text.UTF8Encoding.Default, Request, newTag);
}
}
private static string GetETag(HttpRequestMessage request)
{
IEnumerable<string> values = null;
if (request.Headers.TryGetValues("If-None-Match", out values))
return new EntityTagHeaderValue(values.FirstOrDefault()).Tag;
return null;
}
You need to define how granular to make your tags; my data is user-specific, so I include the UserId in the CacheKey (etag)
Problem
I have a ASP.Net Web API controller which simply returns the list of users. ``` public sealed class UserController : ApiController { [EnableTag] public HttpResponseMessage Get() { var userList= this.RetrieveUserList(); // This will return list of users this.responseMessage = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ObjectContent<List<UserViewModel>>(userList, new JsonMediaTypeFormatter()) }; return this.responseMessage; } } ``` and an action filter attribute class `EnableTag` which is responsible to manage ETag and cache: ``` public class EnableTag : System.Web.Http.Filters.ActionFilterAttribute { private static ConcurrentDictionary<string, EntityTagHeaderValue> etags = new ConcurrentDictionary<string, EntityTagHeaderValue>(); public override void OnActionExecuting(HttpActionContext context) { if (context != null) { var request = context.Request; if (request.Method == HttpMethod.Get) { var key = GetKey(request); ICollection<EntityTagHeaderValue> etagsFromClient = request.Headers.IfNoneMatch; if (etagsFromClient.Count > 0) { EntityTagHeaderValue etag = null; if (etags.TryGetValue(key, out etag) && etagsFromClient.Any(t => t.Tag == etag.Tag)) { context.Response = new HttpResponseMessage(HttpStatusCode.NotModified); SetCacheControl(context.Response); } } } } } public override void OnActionExecuted(HttpActionExecutedContext context) { var request = context.Request; var key = GetKey(request); EntityTagHeaderValue etag; if (!etags.TryGetValue(key, out etag) || request.Method == HttpMethod.Put || request.Method == HttpMethod.Post) { etag = new EntityTagHeaderValue("\"" + Guid.NewGuid().ToString() + "\""); etags.AddOrUpdate(key, etag, (k, val) => etag); } context.Response.Headers.ETag = etag; SetCacheControl(context.Response); } private static void SetCacheControl(HttpResponseMessage response) { response.Headers.CacheControl = new CacheControlHeaderValue() { MaxAge = TimeSpan.FromSeconds(60), MustRevalidate = true, Private = true }; } private static string GetKey(HttpRequestMessage request) { return request.RequestUri.ToString(); } } ``` The above code create an attribute class to manage ETag. So on the first request, it will create a new E-Tag and for the subsequent request it will check whether any ETag is existed. If so, it will generate `Not Modified` HTTP Status and return back to client. My problem is, I want to create a new ETag if there are changes in my user list, ex. a new user is added, or an existing user is deleted. and append it with the response. This can be tracked by the `userList` variable. Currently, the ETag received from client and server are same from every second request, so in this case it will always generate `Not Modified` status, while I want it when actually nothing changed. Can anyone guide me in this direction?