Согласно стандарту 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 должен использовать ответ из контроллера, если он имеется.

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



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

Комментарии (14)


  1. vanxant
    15.11.2017 14:55

    Overkill, нет? У вас вообще как разграничение прав реализовано? У юзера должен быть мандат — список всех его разрешений. Вот его и передавать на клиент один раз при логине


    1. AndreyRodin Автор
      15.11.2017 17:13

      Вы говорите о мандатном управлении доступом. А что насчёт избирательного управления доступом?
      В любом случае, я предлагаю взглянуть на проблему шире, с точки зрения HATEOAS.


  1. SerafimArts
    15.11.2017 15:57

    OPTIONS полюбому придётся реализовывать каждому API, т.к. во время кросдоменных xhr и fetch запросов — первым всегда идёт OPTIONS. Если его не поддержать, то API физически работать не сможет. Ну разве только если оно не направлено сугубо под специфичную платформу, ну например бек для мобильного ПО или что-то такое.


    1. VolCh
      15.11.2017 20:40

      Для таких запросов достаточно веб-сервер настроить для отдачи разрешенных ориджинов в ответ на опшинс запрос.


      1. SerafimArts
        15.11.2017 21:31

        Не всегда это возможно. Например ориджин, зачастую генерируется на основании Refererrer заголовка, т.к. опция "*" не всегда работает (признаться, были там какие-то случаи, связанные с кроссдоменными запросами как раз, но не могу вспомнить). Можно, конечно, запилить плагин к серверу, но надо ли и не проще ли на уровне ПО решать? Помимо этого, по-хорошему надо по-разному формировать ответы на основе роутинга, ну например 404 никто не отменял.

        Т.е. если делать вывод, да, это всё решаемо в зависимости от задачи и ситуации, но всё же без обработки OPTIONS как таковой не обойтись.


        1. VolCh
          15.11.2017 23:59

          Да можно и обойтись, если просто какое-то SPA делать — просто дергать не api.example.com, а example.com/api


          1. SerafimArts
            16.11.2017 03:09

            Так я же не спорю, но всё равно делов на 5 минут, а проблем на одну меньше становится (а точнее возможностей на одну больше).


  1. medvoodoo
    16.11.2017 09:21

    Используем django rest framework. Из коробки OPTIONS, очень удобно.


  1. Nyxem
    16.11.2017 10:34

    В статье описано неправильное использование метода OPTIONS. Этот метод должен говорить в заголовке Allow о всех HTTP-методах, которые поддерживаются по данному URL. Независимо от того, имеет пользователь какие-то права для перечисленных там методов или нет.


    Применить этот метод на практике в REST API очень даже можно. Мы в проекте для документирования API используем спецификацию OpenAPI 3.0. Когда клиент делает обращение по методу OPTIONS, мы парсим документацию и достаем из нее все эндпоинты по запрашиваемому URL (во время парсинга лишние поля вроде описаний выпиливаются). Таким образом клиент может получать правила валидации (допустим, maxLength, required, pattern и пр.), которые будет использовать для валидации форм «на лету» — без отправки на сервер.


    1. AndreyRodin Автор
      16.11.2017 10:34

      А есть доказательства, что использование метода OPTIONS с учётом авторизации – неправильное?


      1. Nyxem
        16.11.2017 23:41

        The OPTIONS method represents a request for information about the communication options available on the request/response chain identified by the Request-URI. RFC 2316, секция 9.2

        Простыми словами: информация должна возвращаться лишь в зависимости от запрашиваемого URL. Другие заголовки (ни Cookie, ни Authorization) не должны влиять на суть ответа. Этого уже достаточно, чтобы понять, что вы всё делаете неправильно.


        Делать какую-либо бизнес-логику или отрисовку интерфейса на основе заголовка Allow в принципе неправильно. Например, есть Google Docs. Что можно получить из Allow:


        • GET — я могу посмотреть документ.
        • PUT & PATCH — я могу изменить документ.
        • DELETE — я могу удалить документ.

        Это всё? Но в 99% приложений бизнес-логика насчитывает куда больше возможностей для управления ресурсами. За одним методом POST может содержаться масса действий, которые могут требовать разные группы доступа. Например, под метод POST можно спрятать копирование документа в текущую папку (POST /api/v1/doc/11/clone), а также возможность переслать его другому пользователю (POST /api/v1/doc/11/share). Как вы будете это с помощью OPTIONS реализовывать? Никак. Этот HTTP-метод для этого не предусмотрен.


        1. AndreyRodin Автор
          17.11.2017 11:13

          Вы всё делаете неправильно

          Призываю вас быть осторожнее в формулировках. Исход нашей дискуссия не повлияет на ценность статьи, даже если я окажусь неправ. Кстати, приведённый в статье код никак не принимает во внимание авторизацию и лишь автоматизирует выдачу ответов по запросу OPTIONS.

          Стандарт говорит:
          This method allows the client to determine the options and/or requirements associated with a resource, or the capabilities of a server, without implying a resource action or initiating a resource retrieval.
          В стандарте идёт речь о цепочке запрос/ответ, определяемой по URI запроса. Надо ли серверу принимать во внимание такие хедеры как Authorization и формировать ответ в зависимости от прав текущего пользователя? Я полагаю, да. В стандарте явного запрета использовать хедеры нет.

          Давайте взглянем на ситуацию с другой точки зрения. Должен ли метод OPTIONS вернуть NotAuthorized, если текущей пользователь не аутентифицирован? Если вы ответите нет, то я скажу, что в вашем проекте что-то не в порядке с безопасностью.

          Почему делать бизнес-логику или отрисовку на основе хедера Allow – неправильно, я не понял. Вы же сами вначале говорили о том, что вы в вашем проекте делаете валидацию форм таким образом?

          Что касается методов, то приведённые вами примеры – это не RESTful подход. Это что-то другое. Вас сразу должно насторожить наличие глаголов в ваших Url.


          1. Nyxem
            17.11.2017 22:35

            Надо ли серверу принимать во внимание такие хедеры как Authorization и формировать ответ в зависимости от прав текущего пользователя? Я полагаю, да.

            И каким образом вы в браузере сделаете запрос к такому эндпоинту? Перед каждым AJAX-запросом на другой ориджин браузер будет самопроизвольно прослушивать запрашиваемый адрес по методу OPTIONS в надежде увидеть успешный ответ, а не какой-нибудь 401, 403 или 404. Вы туда свой Authorization никак не пропихнете. Как вариант, проблему можно решить, храня API и фронт на одном ориджине, но это такое себе решение.


            Должен ли метод OPTIONS вернуть NotAuthorized, если текущей пользователь не аутентифицирован? Если вы ответите нет, то я скажу, что в вашем проекте что-то не в порядке с безопасностью.

            Если пользователь узнает о существовании, скажем, банковской карты с UUID 123e4567-e89b-12d3-a456-426655440000 (например, OPTIONS /v1/bank-cards/123e4567-e89b-12d3-a456-426655440000), то в этом ничего страшного не будет. Да, он узнает, что в системе существует какая-то банковская карта, ID которой был сгенерирован совершенно случайно. О том, как эта карта использовалась в системе и кому она принадлежит, он по методу OPTIONS узнать не сможет. Не вижу весомых причин возвращать 401 Not Authorized в методе OPTIONS. Но если вы можете придумать кейсы, где такое недопустимо, то поведайте о них.


            Почему делать бизнес-логику или отрисовку на основе хедера Allow – неправильно, я не понял. Вы же сами вначале говорили о том, что вы в вашем проекте делаете валидацию форм таким образом?

            Вы неправильно поняли. Мы в теле ответа отдаем JSON по спецификации JSON Schema, который описывает payload, принимаемый бэкендом. Мы парсим нашу документацию из формата Open API 3.0 (которая, кстати, является валидной JSON Schema), вырезаем из нее всё лишнее и отдаем клиенту. Заголовок Allow у нас реализован только для того, чтобы соответствовать спецификации, и чтобы браузер корректно мог кроссдоменные запросы отправлять. Никто кроме браузера в нашей системе на этот заголовок не смотрит.


            Что касается методов, то приведённые вами примеры – это не RESTful подход. Это что-то другое. Вас сразу должно насторожить наличие глаголов в ваших Url.

            Слепо по REST можно писать только TODO-листы в рамках обучения. Увы, в HTTP нет методов CLONE, SHARE и вообще кастомных HTTP-методов, потому глаголы их и заменяют. REST не регламентирует подобное поведение. Если не знаете, какой HTTP-метод использовать, то пишите в конце ресурса глагол и отправляйте туда POST.


            1. AndreyRodin Автор
              17.11.2017 23:56

              Звучит убедительно, спасибо.
              Похоже, идея авторизации и использования OPTIONS в качестве довеска к HATEOAS не всегда проходит.

              Когда я писал статью, я имел в виду чистый RESTful подход безо всяких кроссдоменных запросов. Ну что ж, тогда полезной частью статьи остаётся автоматизация реализации OPTIONS в ASP.NET Web API 2.

              Ещё раз благодарю за подробные ответы!