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?

Original source