Handling conditional requests in ASP.NET Core MVC

This is another post resulting from my work on sample ASP.NET Core MVC powered Web API. This time I'm going to focus on conditional requests. Conditional requests have three main use cases: cache revalidation, concurrency control and range requests. Range requests are primarily used for media like video or audio and I'm not going to write about them here (I did in the past in context of ASP.NET MVC), but the other two are very useful for a Web API.

Adding metadata to the response

Before client can perform a conditional request some metadata should be provided which can be used as validators. The standard defines two types of such metadata: modification dates (delivered by Last-Modified header) and entity tags (delivered by ETag header). Below interface represents those metadata.

interface IConditionalRequestMetadata
{
    string EntityTag { get; }

    DateTime? LastModified { get; }
}

The modification date is simple, it should represent a date and time of the last change to the resource which is being returned. Entity tag is a little bit more complicated. In general entity tag should be unique per representation. This means that entity tag should change not only due to changes over time but also as a result of content negotiation. That second aspect is problematic because it forces entity tag generation to happen very late which can make them impractical. Fortunately standard leaves a gate in a form of weak entity tags. A weak entity tag indicates that the two representations are semantically equivalent, which for Web API usages should be good enough (this approach will break the standard at some point but more about this later). This allows for implementing IConditionalRequestMetadata as part of the demo application model.

public class Character: IConditionalRequestMetadata
{
    private string _entityTag;

    public string Id { get; protected set; }

    ...

    public DateTime LastUpdatedDate { get; protected set; }

    public string EntityTag
    {
        get
        {
            if (String.IsNullOrEmpty(_entityTag))
            {
                _entityTag = "\"" + Id + "-"
                    + LastUpdatedDate.Ticks.ToString(CultureInfo.InvariantCulture) + "\"";
            }

            return _entityTag;
        }
    }

    public DateTime? LastModified { get { return LastUpdatedDate; } }
}

What is missing is some generic mechanism for setting the headers on the response. Result filter is an interesting option for this task. The OnResultExecuting method can be used to inspect if the result from the action is ObjectResult which value is an implementation of IConditionalRequestMetadata. If those conditions are met the headers can be set.

internal class ConditionalRequestFilter : IResultFilter
{
    public void OnResultExecuted(ResultExecutedContext context)
    { }

    public void OnResultExecuting(ResultExecutingContext context)
    {
        IConditionalRequestMetadata metadata = (context.Result as ObjectResult)?.Value
            as IConditionalRequestMetadata;

        if (metadata != null)
        {
            SetConditionalMetadataHeaders(context, metadata);
        }
    }

    private static void SetConditionalMetadataHeaders(ResultExecutingContext context,
        IConditionalRequestMetadata metadata)
    {
        ResponseHeaders responseHeaders = context.HttpContext.Response.GetTypedHeaders();

        if (!String.IsNullOrWhiteSpace(metadata.EntityTag))
        {
            responseHeaders.ETag = new EntityTagHeaderValue(metadata.EntityTag, true);
        }

        if (metadata.LastModified.HasValue)
        {
            responseHeaders.LastModified = metadata.LastModified.Value;
        }
    }
}

After registering the filter the headers will be available on every response for which the underlying model provides the metadata.

Cache revalidation

Typically client will use one of two headers as part of GET or HEAD request in order to perform cache revalidation: If-None-Match or If-Modified-Since. Simple extension method can be used to extract both from request.

internal class HttpRequestConditions
{
    public IEnumerable<string> IfNoneMatch { get; set; }

    public DateTimeOffset? IfModifiedSince { get; set; }
}
internal static class HttpRequestExtensions
{
    internal static HttpRequestConditions GetRequestConditions(this HttpRequest request)
    {
        HttpRequestConditions requestConditions = new HttpRequestConditions();

        RequestHeaders requestHeaders = request.GetTypedHeaders();

        if (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method))
        {
            requestConditions.IfNoneMatch = requestHeaders.IfNoneMatch?.Select(v => v.Tag.ToString());
            requestConditions.IfModifiedSince = requestHeaders.IfModifiedSince;
        }

        return requestConditions;
    }
}

The If-None-Match is considered to be a more accurate (if both are present in the request only If-None-Match should be evaluated). It can contain one or more entity tags which represent versions of the resource cached by the client. If the current entity tag of the resource is on that list the server should respond with 304 Not Modified (with no body) instead of normal response.

The If-Modified-Since works similarly. It contains last modification date of the resource known by client. If that modification date is equal (or potentially later) to the modification date of the resource the server should also respond with 304 Not Modified.

It's hard to optimize for cache revalidation on the server side unless the metadata of the resource are cheaply accessible (for example a static file). For typical scenarios which involve some kind of database as store it usually results in multiple queries or a complex one. Because of that it's often good enough to retrieve the resource from store and validate later. The ConditionalRequestFilter can be extended to do that.

internal class ConditionalRequestFilter : IResultFilter
{
    ...

    public void OnResultExecuting(ResultExecutingContext context)
    {
        IConditionalRequestMetadata metadata = (context.Result as ObjectResult)?.Value
            as IConditionalRequestMetadata;

        if (metadata != null)
        {
            if (CheckModified(context, metadata))
            {
                SetConditionalMetadataHeaders(context, metadata);
            }
        }
    }

    private static bool CheckModified(ResultExecutingContext context,
        IConditionalRequestMetadata metadata)
    {
        bool modified = true;

        HttpRequestConditions requestConditions = context.HttpContext.Request.GetRequestConditions();

        if ((requestConditions.IfNoneMatch != null) && requestConditions.IfNoneMatch.Any())
        {
            if (!String.IsNullOrWhiteSpace(metadata.EntityTag)
                && requestConditions.IfNoneMatch.Contains(metadata.EntityTag))
            {
                modified = false;
                context.Result = new StatusCodeResult(StatusCodes.Status304NotModified);
            }
        }
        else if (requestConditions.IfModifiedSince.HasValue && metadata.LastModified.HasValue)
        {
            DateTimeOffset lastModified = metadata.LastModified.Value.AddTicks(
                -(metadata.LastModified.Value.Ticks % TimeSpan.TicksPerSecond));

            if (lastModified <= requestConditions.IfModifiedSince.Value)
            {
                modified = false;
                context.Result = new StatusCodeResult(StatusCodes.Status304NotModified);
            }
        }

        return modified;
    }

    ...
}

This way cache revalidation is being handled automatically for resources which support it.

Concurrency control

The concurrency control can be considered an opposite mechanism to cache revalidation. Its goal is to prevent the change of a resource (usually in result of PUT or PATCH request) if it has been already modified by another user (the Lost Update problem). The headers used to achieve this goal are counterparts of those used in cache revalidation: If-Match and If-Unmodified-Since. The previously created extension method can extract those as well.

internal class HttpRequestConditions
{
    ...

    public IEnumerable<string> IfMatch { get; set; }

    public DateTimeOffset? IfUnmodifiedSince { get; set; }
}
internal static class HttpRequestExtensions
{
    internal static HttpRequestConditions GetRequestConditions(this HttpRequest request)
    {
        ...

        if (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method))
        {
            ...
        }
        else if (HttpMethods.IsPut(request.Method) || HttpMethods.IsPatch(request.Method))
        {
            requestConditions.IfMatch = requestHeaders.IfMatch?.Select(v => v.Tag.ToString());
            requestConditions.IfUnmodifiedSince = requestHeaders.IfUnmodifiedSince;
        }

        return requestConditions;
    }
}

The If-Unmodified-Since is an exact opposite of If-Modified-Since which means that last modification date of the resource can't be later than the one provided. If it is, the operation shouldn't be performed and the response should be 412 Precondition Failed.

The If-Match is a little bit more tricky. Similar to If-None-Match it provides a list of entity tags and the current entity tag of the resource is required to be present on that list, but the standard disallows usage of weak entity tags here. This guarantees safety if different representations are stored separately, but for modern Web APIs this is often not a case. Different representations are a result of transforming the source resource which is stored only once. Because of that I believe that not following standard in this case is acceptable. One more thing is handling * value (which I've skipped for If-None-Match) - it means that resource should have at least one current representation. If the considered methods are only PUT and PATCH this condition should always evaluate to true (the absence of resource should be checked earlier and result in 404 Not Found).

All those rules can be encapsulated within a single method.

private bool CheckPreconditionFailed(HttpRequestConditions requestConditions,
    IConditionalRequestMetadata metadata)
{
    bool preconditionFailed = false;

    if ((requestConditions.IfMatch) != null && requestConditions.IfMatch.Any())
    {
        if ((requestConditions.IfMatch.Count() > 2) || (requestConditions.IfMatch.First() != "*"))
        {
            if (!requestConditions.IfMatch.Contains(metadata.EntityTag))
            {
                preconditionFailed = true;
            }
        }
    }
    else if (requestConditions.IfUnmodifiedSince.HasValue)
    {
        DateTimeOffset lastModified = metadata.LastModified.Value.AddTicks(
            -(metadata.LastModified.Value.Ticks % TimeSpan.TicksPerSecond));

        if (lastModified > requestConditions.IfUnmodifiedSince.Value)
        {
            preconditionFailed = true;
        }
    }

    return preconditionFailed;
}

This method should be used as part of an action flow. This can be done more or less generic depending on the application architecture (for example CQRS opens much more options). In simplest case it can be called directly by every action which needs it.

One last thing

Described here are most typical usages of If-Match, If-None-Match, If-Modified-Since and If-Unmodified-Since which doesn't exhaust the subject. The headers can be used with other methods than mentioned or have special usages (like If-None-Match with value of *). As always, when in doubt the standard is your friend.