Click here to Skip to main content
14,384,144 members

How to Implement OPTIONS Response in ASP.NET Web API 2

Rate this:
4.31 (7 votes)
Please Sign up or sign in to vote.
4.31 (7 votes)
13 Nov 2017CPOL
Automation of OPTIONS response in REST-service

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;

    // IsAuthenticated() returns true if current user is authenticated
    return IsAuthenticated() ? ResponseAction.RetrieveMethods : ResponseAction.ReturnUnauthorized;
}

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Andrey Rodin
Russian Federation Russian Federation
No Biography provided

Comments and Discussions

 
Questioncode Pin
Lecha Bisultanov12-Dec-17 1:11
memberLecha Bisultanov12-Dec-17 1:11 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Article
Posted 21 Jul 2017

Tagged as

Stats

20.9K views
5 bookmarked