Exploring HEAD method behavior in ASP.NET Core

In last couple weeks I've been playing with ASP.NET Core MVC powered Web API. One of things I wanted to dig deeper into is support for HEAD method. The specification says that "The HEAD method is identical to GET except that the server MUST NOT return a message-body in the response. The metainformation contained in the HTTP headers in response to a HEAD request SHOULD be identical to the information sent in response to a GET request.". In practice the HEAD method is often being used for performing "exists" requests.

How ASP.NET Core is handling HEAD at the server level

Before looking at higher layers it is worth to understand what is the behavior of underlying server in case of HEAD request. The sample Web API mentioned in the beginning has a following end-middleware for handling cases when none of routes has been hit, it will be perfect for this task.

public class Startup
{
    ...

    public void Configure(IApplicationBuilder app)
    {
        ...

        app.Run(async (context) =>
        {
            context.Response.ContentLength = 34;
            await context.Response.WriteAsync("-- Demo.AspNetCore.Mvc.CosmosDB --");
        });
    }
}

First testing environment will be Kestrel. Response to a GET request (which will be used as baseline) looks like below.

HTTP/1.1 200 OK
Content-Length: 34
Date: Mon, 02 Oct 2017 19:22:38 GMT
Server: Kestrel

-- Demo.AspNetCore.Mvc.CosmosDB --

Switching the method to HEAD (without any changes to the code) results in following.

HTTP/1.1 200 OK
Content-Length: 34
Date: Mon, 02 Oct 2017 19:22:38 GMT
Server: Kestrel

This shows that Kestrel is handling HEAD request quite nicely out of the box. All the headers are there and the write to the response body has been ignored. This is the exact behavior one should expect.

With this positive outcome application can be switched to the second testing environment which will be HTTP.sys server. Here the response to HEAD request is different.

HTTP/1.1 200 OK
Content-Length: 34
Date: Mon, 02 Oct 2017 19:25:43 GMT
Server: Microsoft-HTTPAPI/2.0

-- Demo.AspNetCore.Mvc.CosmosDB --

Unfortunately this is a malformed response as it contains body, which is incorrect from specification perspective and also removes the performance gain which HEAD request offers. This is something that should be addressed, but before that let's take a look at more complex scenario.

Adding ASP.NET Core MVC on top

Knowing how the servers are handling the HEAD method the scenario can be extended by adding MVC to the mix. For this purpose a simple GET action which takes an identifier as parameter can be used. The important part is that the action should return 404 Not Found for identifier which doesn't exist.

[Route("api/[controller]")]
public class CharactersController : Controller
{
    private readonly IMediator _mediator;

    public CharactersController(IMediator mediator)
    {
        _mediator = mediator;
    }

    ...

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(string id)
    {
        Character character = await _mediator.Send(new GetSingleRequest<Character>(id));
        if (character == null)
        {
            return NotFound();
        }

        return new ObjectResult(character);
    }

    ...
}

In context of previous discoveries testing environments can be limited to Kestrel only. Making a GET request with valid identifier results in response with JSON body.

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Mon, 02 Oct 2017 19:40:25 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"id":"1ba6271109d445c8972542985b2d3e96","createdDate":"2017-09-24T21:08:50.9990689Z","lastUpdatedDate":"2017-09-24T21:08:50.9990693Z","name":"Leia Organa","gender":"Female","height":150,"weight":49,"birthYear":"19BBY","skinColor":"Light","hairColor":"Brown","eyeColor":"Brown"}

Switching to HEAD produces a response which might be a little surprising.

HTTP/1.1 200 OK
Content-Length: 34
Date: Mon, 02 Oct 2017 19:42:10 GMT
Server: Kestrel

The presence of Content-Length and absence of Content-Type suggest this is not the response from the intended endpoint. In fact it looks like a response from the end-middleware. A request with invalid identifier returns exactly same response instead of expected 404. Taking one more look at the code should reveal why this shouldn't be a surprise. The action is decorated with HttpGetAttribute which makes it unreachable by HEAD request, in result application has indeed defaulted to the end-middleware. Adding HttpHeadAttribute should solve the problem.

[Route("api/[controller]")]
public class CharactersController : Controller
{
    ...

    [HttpGet("{id}")]
    [HttpHead("{id}")]
    public async Task<IActionResult> Get(string id)
    {
        ...
    }

    ...
}

After this change both HEAD requests (with valid and invalid identifier) return expected responses.

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Mon, 02 Oct 2017 19:44:23 GMT
Server: Kestrel
HTTP/1.1 404 Not Found
Date: Mon, 02 Oct 2017 19:48:07 GMT
Server: Kestrel

This means that an action needs to be decorated with two attributes. Separating between GET and HEAD makes perfect sense when it's possible to optimize the HEAD request handling on server side but for simple scenario like this one it seems unnecessary. One possible improvement is custom HttpMethodAttribute which would allow both methods.

public class HttpGetOrHeadAttribute : HttpMethodAttribute
{
    private static readonly IEnumerable<string> _supportedMethods = new[] { "GET", "HEAD" };

    public HttpGetOrHeadAttribute()
        : base(_supportedMethods)
    { }

    public HttpGetOrHeadAttribute(string template)
        : base(_supportedMethods, template)
    {
        if (template == null)
        {
            throw new ArgumentNullException(nameof(template));
        }
    }
}

Still anybody who will be working on the project in future will have to know that a custom attribute must be used. It might be preferred to have a solution which can be applied once, especially keeping in mind that there is also HTTP.Sys issue to be solved.

Solving the problems in one place

In context of ASP.NET Core "one place" typically ends up being some kind of middleware. In this case a middleware could be used to perform an old trick of switching incoming HEAD request to GET. The switch should be only temporary, otherwise the Kestrel integrity checks might fail due to Content-Length being different from actual number of bytes written. There is also one important thing to remember. After switching the method Kestrel will stop ignoring writes to the body. The easiest solution to this is to change body stream to Stream.Null (this will also fix the problem observed in case of HTTP.Sys server).

public class HeadMethodMiddleware
{
    private readonly RequestDelegate _next;

    public HeadMethodMiddleware(RequestDelegate next)
    {
        _next = next ?? throw new ArgumentNullException(nameof(next));
    }

    public async Task Invoke(HttpContext context)
    {
        bool methodSwitched = false;

        if (HttpMethods.IsHead(context.Request.Method))
        {
            methodSwitched = true;

            context.Request.Method = HttpMethods.Get;
            context.Response.Body = Stream.Null;
        }

        await _next(context);

        if (methodSwitched)
        {
            context.Request.Method = HttpMethods.Head;
        }
    }
}

This middleware should be applied with caution. Some middlewares (for example StaticFiles) can have their own optimized handling of HEAD method. It is also possible that in case of some middlewares switching method can result in undesired side effects.