SoftSan
SoftSan

Reputation: 2472

How to use ETag in Web API using action filter along with HttpResponseMessage

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?

Upvotes: 18

Views: 39174

Answers (5)

Major
Major

Reputation: 6698

I like the answer which was provided by @Viezevingertjes. It is the most elegant and "No need for setting up anything" approach is very convenient. I like it too :)

However I think it has a few drawbacks:

  • The whole OnActionExecuting() method and storing ETags in _receivedEntityTags is unnecessary because the Request is available inside the OnActionExecuted method as well.
  • Only works with ObjectContent response types.
  • Extra work load because of the serialization.

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.

So that's why I decided to pimp it up a little but. Simplified filter no need for OnActionExecuting method, works with Any response types, no Serialization. And most importantly adds CacheControl header as well. 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)]

Upvotes: 5

James Joyce
James Joyce

Reputation: 1774

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)

Upvotes: 8

Viezevingertjes
Viezevingertjes

Reputation: 1577

I found CacheCow very bloated for what it does, if the only reason is, to lower the amount of data transfered, you might want to use something like this:

public class EntityTagContentHashAttribute : ActionFilterAttribute
{
    private IEnumerable<string> _receivedEntityTags;

    private readonly HttpMethod[] _supportedRequestMethods = {
        HttpMethod.Get,
        HttpMethod.Head
    };

    public override void OnActionExecuting(HttpActionContext context) {
        if (!_supportedRequestMethods.Contains(context.Request.Method))
            throw new HttpResponseException(context.Request.CreateErrorResponse(HttpStatusCode.PreconditionFailed,
                "This request method is not supported in combination with ETag."));

        var conditions = context.Request.Headers.IfNoneMatch;

        if (conditions != null) {
            _receivedEntityTags = conditions.Select(t => t.Tag.Trim('"'));
        }
    }

    public override void OnActionExecuted(HttpActionExecutedContext context)
    {
        var objectContent = context.Response.Content as ObjectContent;

        if (objectContent == null) return;

        var computedEntityTag = ComputeHash(objectContent.Value);

        if (_receivedEntityTags.Contains(computedEntityTag))
        {
            context.Response.StatusCode = HttpStatusCode.NotModified;
            context.Response.Content = null;
        }

        context.Response.Headers.ETag = new EntityTagHeaderValue("\"" + computedEntityTag + "\"", true);
    }

    private static string ComputeHash(object instance) {
        var cryptoServiceProvider = new MD5CryptoServiceProvider();
        var serializer = new DataContractSerializer(instance.GetType());

        using (var memoryStream = new MemoryStream())
        {
            serializer.WriteObject(memoryStream, instance);
            cryptoServiceProvider.ComputeHash(memoryStream.ToArray());

            return String.Join("", cryptoServiceProvider.Hash.Select(c => c.ToString("x2")));
        }
    }
}

No need for setting up anything, set and forget. The way i like it. :)

Upvotes: 5

Seems to be a nice way to do it:

public class CacheControlAttribute : System.Web.Http.Filters.ActionFilterAttribute
{
    public int MaxAge { get; set; }

    public CacheControlAttribute()
    {
        MaxAge = 3600;
    }

    public override void OnActionExecuted(HttpActionExecutedContext context)
    {
        if (context.Response != null)
        {
            context.Response.Headers.CacheControl = new CacheControlHeaderValue
            {
                Public = true,
                MaxAge = TimeSpan.FromSeconds(MaxAge)
            };
            context.Response.Headers.ETag = new EntityTagHeaderValue(string.Concat("\"", context.Response.Content.ReadAsStringAsync().Result.GetHashCode(), "\""),true);
        }
        base.OnActionExecuted(context);
    }
}

Upvotes: -1

imperugo
imperugo

Reputation: 385

a good solution for ETag and in ASP.NET Web API is to use CacheCow . A good article is here.

It's easy to use and you don't have to create a custom Attribute. Have fun .u

Upvotes: 6

Related Questions