Saturday, June 25, 2011

ETags with WCF Web APIs Preview 4

Jose Romaniello wrote a very nice blog post about to handle HTTP cache validation with ETags and the WCF Web APIs. For the ETags server implementation there are two different aspects to be considered: the code that checks if a resource has been modified based on the incoming request ETag value (this is handled by a DelegatingChannel in Jose’s implementation), and the part where the resource ETag is added to the response so the client can use this value to make further requests (In Jose’s implementation this is handled on case by case basis in every resource handler). I was interested to see if the second part could be handled in a generic and automated way by using some conventions, so the custom logic in the resource handler could be spared. So let’s get started!

First of all, let’s define the resource.

public class ProductResource
{
    public int Id { get; set; }
    public string Name { get; set; }
    public float Price { get; set; }
    public string ETag { get; set; }
}

As in Jose’s post, for the ETag value we can use the DB version field for example. The resource handler goes as follows.

[ServiceContract]
public class ProductResourceHandler
{
    private readonly ProductRepository _productRepository = new ProductRepository();
 
    [WebGet(UriTemplate = "{id}")]
    public HttpResponseMessage<ProductResource> Get(int id)
    {
        var product = _productRepository.Get(id);
        return product == null ? new HttpResponseMessage<ProductResource>(HttpStatusCode.NotFound) 
                               : new HttpResponseMessage<ProductResource>(product);
    }
 
    [WebInvoke(UriTemplate = "{id}", Method = "PUT")]
    public ProductResource Put(int id, ProductResource product)
    {
        _productRepository.Save(product);
        return product;
    }
 
    [WebInvoke(UriTemplate = "{id}", Method = "DELETE")]
    public HttpResponseMessage Delete(int id)
    {
        var product = _productRepository.Get(id);
        if (product == null)
            return new HttpResponseMessage<ProductResource>(HttpStatusCode.NotFound);
        _productRepository.Delete(product);
        return new HttpResponseMessage(HttpStatusCode.OK, "Deleted");
    }
}
The EntityTagResponseHandlerChannel handles the ETag injection to the responses. On top of that, when a PUT or DELETE request succeeds, the channel removes the ETag associated to the incoming url from the cache. This ensures that further GET request won’t get a stale version of the resource.

   1: public class EntityTagResponseHandlerChannel : DelegatingChannel
   2: {
   3:     private readonly ETagCache _eTagCache = ETagCacheProvider.Instance;
   4:  
   5:     public EntityTagResponseHandlerChannel(HttpMessageChannel innerChannel)
   6:         : base(innerChannel)
   7:     {
   8:     }
   9:  
  10:     protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, 
  11:                                                             CancellationToken cancellationToken)
  12:     {
  13:         return base.SendAsync(request, cancellationToken).ContinueWith(task =>
  14:         {
  15:             var response = task.Result;
  16:             if (response.StatusCode != HttpStatusCode.OK) return response;
  17:             var requestMethod = response.RequestMessage.Method;
  18:             if (requestMethod == HttpMethod.Put || requestMethod == HttpMethod.Delete)
  19:             {
  20:                 _eTagCache.Remove(response.RequestMessage.RequestUri.ToString());
  21:             }
  22:             else if (requestMethod == HttpMethod.Get || requestMethod == HttpMethod.Head)
  23:             {
  24:                 if (SetETag(response))
  25:                 {
  26:                     _eTagCache.Add(response.RequestMessage.RequestUri.ToString(), 
  27:                                     response.Headers.ETag.ToString());
  28:                 }
  29:             }
  30:  
  31:             return response;
  32:         });
  33:     }
  34:  
  35:     private static bool SetETag(HttpResponseMessage response)
  36:     {
  37:         if (response.Headers.ETag != null || response.Content == null)
  38:             return false;
  39:         dynamic content = response.Content;
  40:         response.Headers.ETag = ETagProvider.GetETag(content.ReadAs());
  41:         return response.Headers.ETag != null;
  42:     }
  43: }
Note that we check if the ETag has been previously been set (line 37) meaning that custom ETag handling logic in the resource handler overrides the Channel logic.

The tricky part here is to get the ETag value from the resource associated to the response. The object contained in the response variable is a generic HttpResponseMessage<TResource> that contains the original resource. The problem is that, as the DelegatingChannels are non-generic objects, they can only reference the non-generic versions of the response entities (HttpResponseMessage, ObjectContent base classes) which don’t have access to the original resource object.  Since casting to the generic version is out of the question (this Channel should handle any resource type request), we take advantage of a dynamic variable to execute the generic ReadAs method that gives us the original resource entity (line 40).

Finally the ETagProvider retrieves the ETag from the resource object if exists.

   1: static class ETagProvider
   2: {
   3:     private static readonly ConcurrentDictionary<Type, PropertyInfo> ETagPropertyInfoByType = 
   4:         new ConcurrentDictionary<Type, PropertyInfo>();
   5:     private const string ETagPropertyName = "ETag";
   6:  
   7:     internal static EntityTagHeaderValue GetETag(object resource)
   8:     {
   9:         if (resource == null) return null;
  10:         var resourceType = resource.GetType();
  11:         PropertyInfo propertyInfo;
  12:         if (!ETagPropertyInfoByType.TryGetValue(resourceType, out propertyInfo))
  13:         {
  14:             propertyInfo = resourceType.GetProperty(ETagPropertyName);
  15:             ETagPropertyInfoByType.AddOrUpdate(resourceType, propertyInfo, (t, p) => p);
  16:         }
  17:         return GetETag(resource, propertyInfo);
  18:     }
  19:  
  20:     private static EntityTagHeaderValue GetETag(object resource, PropertyInfo propertyInfo)
  21:     {
  22:         return propertyInfo == null ? null
  23:                                     : new EntityTagHeaderValue(String.Format(CultureInfo.InvariantCulture,
  24:                                                                 "\"{0}\"",
  25:                                                                 propertyInfo.GetValue(resource, null)));
  26:     }
  27: }

The ETag property is retrieved by using Reflection and a dictionary of PropertyInfos by type is used to improve performance.

In closing


HTTP cache expiration could be implemented in a similar way by using for example a .NET attribute defining the resource MaxAge, then a DelegatingChannel would grab this value and add it to the associated response.

Nevertheless, bear in mind that this is by no means production-ready code! My main purpose with this post was to show how the Wcf Web Apis infrastructure can be leveraged to implement HTTP protocol features such as cache validation.

4 comments:

  1. Unlike Jose, you don't seem to be returning a 304 NotModified if the ETag matches, how is your solution handling this?

    ReplyDelete
  2. For returning 304 NotModified you can use the DelegatingChannel from Jose's post.
    If you want to add the ETags automatically you can use the code in the post.

    As I mention in the 1st paragraph this focuses on the 2nd part of the problem, the ETag handling; the 1st part is well covered by Jose's DelegatingChannel

    Hope this helps

    ReplyDelete
  3. Thanks. So you could in combine both approaches into a single DelegatingHandler? BTW You might also need to update the ReadAs() since Preview 6 no longer has this method and unfortunately seems to require accessing the .Result property of an async task (e.g. content.ReadAsAsync().Result) - this may have performance implications but probably better than the concurrency implications of setting headers async.

    ReplyDelete
  4. Yep, the idea was to use the two DelegatingChannels together... You could merge them in one, but as they are doing two different things, (Single Responsibility Principle), they're better off the way the are IMHO. A custom configuration builder component should make sure they are both injected in the pipeline.

    To be honest I haven't checked the Preview 6 and I think that I'll probably wait until the RTM or Beta is released to take another look...

    ReplyDelete