HTTP OPTIONS verb is a good opportunity to make REST-service self-documented: http://zacstewart.com/2012/04/14/http-options-method.html.
Minimal requirement is to have Allow
header in response content, enumerating all available methods for the given URI, so implementation is rather easy:
[HttpOptions]
[ResponseType(typeof(void))]
[Route("Books", Name = "Options")]
public IHttpActionResult Options()
{
HttpContext.Current.Response.AppendHeader("Allow", "GET,OPTIONS");
return Ok();
}
Response:
HTTP/1.1 200 OK
Allow: GET,OPTIONS
Content-Length: 0
However, this approach has several serious shortcomings. We have to maintain the list of supported methods manually and we have to repeat this for each controller and for every supported URI. It’s very easy to forget to add appropriate OPTIONS
action when we introduce new URI. So, it would be great to add some automation here.
ASP.NET Web API provides a nice way to have a more high-level solution: HTTP message handlers.
I inherited a new handler from DelegatingHandler
, overwrote SendAsync
method and added my functionality as continuation of base task. This is important because I want to run basic route mechanism before any processing. In that case, request will contain all needed properties.
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
return await base.SendAsync(request, cancellationToken).ContinueWith(
task =>
{
var response = task.Result;
if (request.Method == HttpMethod.Options)
{
var methods = new ActionSelector(request).GetSupportedMethods();
if (methods != null)
{
response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(string.Empty)
};
response.Content.Headers.Add("Allow", methods);
response.Content.Headers.Add("Allow", "OPTIONS");
}
}
return response;
}, cancellationToken);
}
Class ActionSelector
attempts to find the appropriate controller for the given request in constructor. If controller not found, method GetSupportedMethods
returns null
. Function IsMethodSupported
makes a trick, creating new request with required method, and checks if action is found. Finally
block restores old routeData
in context because _apiSelector.SelectAction
call can change it.
private class ActionSelector
{
private readonly HttpRequestMessage _request;
private readonly HttpControllerContext _context;
private readonly ApiControllerActionSelector _apiSelector;
private static readonly string[] Methods =
{ "GET", "PUT", "POST", "PATCH", "DELETE", "HEAD", "TRACE" };
public ActionSelector(HttpRequestMessage request)
{
try
{
var configuration = request.GetConfiguration();
var requestContext = request.GetRequestContext();
var controllerDescriptor = new DefaultHttpControllerSelector(configuration)
.SelectController(request);
_context = new HttpControllerContext
{
Request = request,
RequestContext = requestContext,
Configuration = configuration,
ControllerDescriptor = controllerDescriptor
};
}
catch
{
return;
}
_request = _context.Request;
_apiSelector = new ApiControllerActionSelector();
}
public IEnumerable<string> GetSupportedMethods()
{
return _request == null ? null : Methods.Where(IsMethodSupported);
}
private bool IsMethodSupported(string method)
{
_context.Request = new HttpRequestMessage(new HttpMethod(method), _request.RequestUri);
var routeData = _context.RouteData;
try
{
return _apiSelector.SelectAction(_context) != null;
}
catch
{
return false;
}
finally
{
_context.RouteData = routeData;
}
}
}
The last step is adding our message handler to configuration in start-up code:
configuration.MessageHandlers.Add(new OptionsHandler());
With this approach, REST service will create OPTIONS
response for every valid URI.
Don’t forget to specify parameter types. If you use attribute routing, specify type in Route
attribute:
[Route("Books/{id:int}", Name = "GetBook")]
Without implicit specification, ActionSelector
will consider path “Books/abcd
” as valid.
But this is still not a good solution. First of all, the handler does not take into account authorization. Secondly, the service will respond on request, even if URI contains wrong resource ID, for example /books/0. This dramatically reduces the practical advantage of using OPTIONS
verbs.
If we want authorization of resources, we have to add controller action OPTIONS
for each URI that contains resource Ids, because only controllers can authorize resources. At that, our handler should use response from the action if any.
Final implementation:
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
return await base.SendAsync(request, cancellationToken).ContinueWith(
task =>
{
var response = task.Result;
switch (GetResponseAction(request, response))
{
case ResponseAction.UseOriginal:
return response;
case ResponseAction.ReturnUnauthorized:
return new HttpResponseMessage(HttpStatusCode.Unauthorized);
case ResponseAction.ReturnUnauthorized:
var methods = new ActionSelector(request).GetSupportedMethods();
if (methods == null)
return response;
response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(string.Empty)
};
response.Content.Headers.Add("Allow", methods);
response.Content.Headers.Add("Allow", "OPTIONS");
return response;
default:
throw new InvalidOperationException("Unsupported response action code");
}
}, cancellationToken);
}
private enum ResponseAction
{
UseOriginal,
RetrieveMethods,
ReturnUnauthorized
}
private static ResponseAction GetResponseAction(
HttpRequestMessage request, HttpResponseMessage response)
{
if (request.Method != HttpMethod.Options)
return ResponseAction.UseOriginal;
if (response.StatusCode != HttpStatusCode.MethodNotAllowed)
return ResponseAction.UseOriginal;
return IsAuthenticated() ? ResponseAction.RetrieveMethods : ResponseAction.ReturnUnauthorized;
}