Нужен ли метод OPTIONS в REST-сервисах? +6


Согласно стандарту HTTP/1.1 метод OPTIONS может быть использован клиентом для определения параметров или требований, связанных с ресурсом. Сервер также может отправлять документацию в удобочитаемом формате. Ответ на запрос OPTIONS может содержать список допустимых методов для данного ресурса в хедере Allow.

То есть этот метод мог бы стать отличным средством для документирования наших REST-сервисов с одной стороны, и быть существенным дополнением к архитектурному ограничению HATEOAS с другой.

А теперь давайте отвлечёмся от страшных слов типа “HATEOAS” и зададимся вопросом: а есть ли какая-нибудь практическая польза от использования метода OPTIONS в веб-приложениях?

Итак, по минимуму, ответ на запрос OPTIONS должен содержать список методов, допустимых для данной конечной точки или попросту Uri, в хедере Allow.

HTTP/1.1 200 OK
Allow: GET,POST,DELETE,OPTIONS
Content-Length: 0

Что это даёт?

Представьте себе, что у нас есть веб-приложение, позволяющее размещать ресурсы и работать с ними. Ну, например, что-то вроде Google Docs. Каждый пользователь имеет определённые права на документ: кто-то может читать его, кто-то редактировать, а кто-то удалять.

Перед нами стоит задача разработки пользовательского интерфейса. Грубо говоря, нам надо в определённый момент решить, каким образом мы будем прятать или показывать кнопку Delete в зависимости от полномочий текущего пользователя.

Можно получать знания о полномочиях из сервиса и реализовывать логику на стороне клиента. Но это плохой подход.

В REST-архитектуре клиент и сервер должны быть максимально независимыми. Не дело клиента выяснять, какими полномочиями должен обладать пользователь, чтобы иметь возможность удалить документ. Если клиент будет реализовывать свою логику, то изменение механизма полномочий на сервере приведёт, скорее всего, к необходимости изменений на клиенте.

Если же мы будем иметь возможность запросить OPTIONS по Uri документа и получить список допустимых методов, задача решается просто: если хедер Allow содержит “Delete”, значит, кнопку надо показать, иначе – спрятать.

Реализовать метод OPTIONS нетрудно.

[HttpOptions]
[ResponseType(typeof(void))]
[Route("Books", Name = "Options")]
public IHttpActionResult Options()
{
    HttpContext.Current.Response.AppendHeader("Allow", "GET,OPTIONS");
    return Ok();
}

Но имплементировать методы для каждого контроллера и каждого маршрута затруднительно. Проблема в том, что поддерживать это хозяйство довольно хлопотно: очень просто забыть добавить соответствующий метод в контроллер, либо допустить ошибку при изменениях в маршрутизации.

Нельзя ли автоматизировать процесс?

ASP.NET Web API предоставляет хорошую возможность для автоматизации: HTTP Message Handlers.

Унаследуем наш новый обработчик от класса DelegatingHandler, перегрузим метод SendAsync и добавим новую функциональность как продолжение таска. Это важно, поскольку мы хотим запустить вначале базовый механизм маршрутизации. В таком случае переменная request будет содержать все необходимые свойства.

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);
}

Класс ActionSelector пытается найти подходящий контроллер для запроса в конструкторе. Если контроллер не найден, метод GetSupportedMethods вернёт null. Функция IsMethodSupported подменяет текущий запрос в контексте для того, чтобы найти action-метод по заданному имени. Блок finally восстанавливает RouteData, поскольку вызов _apiSelector.SelectAction может изменить это свойство контекста.

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;
        }
    }
}

Финальный шаг – это добавление нашего обработчика в конфигурацию в startup-коде:

configuration.MessageHandlers.Add(new OptionsHandler());

Чтобы всё корректно работало, необходимо явно указывать типы параметров. Если используется атрибут Route, тип надо указывать прямо в нём:

[Route("Books/{id:long}", Name = "GetBook")]

Без явного указания типа путь Books/abcd будет рассматриваться как корректный.

Итак, теперь мы имеем реализацию OPTIONS для всех поддерживаемых Uri в сервисе. Однако, это всё ещё не идеальное решение. Описанный подход никак не учитывает авторизацию. Если мы используем маркеры доступа и каждый вызов сервиса предусматривает наличие текущего принципала, то его права никак не учитываются и ценность метода OPTIONS резко снижается. Для любого пользователя клиент всегда будет получать один и тот же список допустимых HTTP методов независимо от его прав.

Кроме того, сервис будет отвечать на запрос с кодом 200 OK даже если Uri содержит неверный идентификатор ресурса, например, /books/0.

Проблема возникает везде, где используются идентификаторы ресурсов. Выходом из этого может стать ручное добавление в контроллеры реализаций HttpOptions там, где это нужно. Эти action-методы должны проводить проверку прав пользователя при формировании списка допустимых методов. При этом наш OptionsHandler должен использовать ответ из контроллера, если он имеется.

> Финальный код



Авторский перевод




К сожалению, не доступен сервер mySQL