Адаптация статьи REST WORST PRACTICES, © Jacob Kaplan-Moss. Статья написана применительно к Django, но информация будет актуальна для широкого круга специалистов.

Думаю что лучший способ понять как нужно делать, изучить как делать НЕ нужно. Представляю вашему вниманию вредные советы проектировщикам REST API.

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

Объединяй модели и ресурсы


В мире REST, ресурс — это центральное понятие. Очень заманчиво просто взять модель (строка в таблице), сказать что это и есть ресурс — одна модель, один ресурс. Это решение проваливается как только возникает задача — представить некий вид составного ресурса и уж точно проваливается в сильно денормализованных моделях.

Представьте модель Супергерой: вот единственный запрос GET /heros/superman/ который должен вернуть все его характеристики: список друзей, список связанных с ним артефактов и т.д. Смотрите, данные, ассоциированные с ресурсом, на самом деле могут приходить из нескольких моделей. Тут же полезно вспомнить паттерн фасад — ресурс это фасад для моделей, а не конкретная модель.

Жестко зашивай конкретную подсистему контроля доступа в код API


Способ контроля доступа должен быть подключаемым. Модуль контроля доступа должен определять какие операции над какими объектами допустимы — это единственный способ заставить работать что-то сложное.

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

Делай формат возвращаемых данных зависимым от типа ресурса


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

Вспомните Yahoo APIs — формат ResultSet/Result везде одинаков. Так же и Atom (GData).

Основная мысль — клиентский код не должен знать как парсить множество различных форматов данных.

Пусть твой API поддерживает только один формат возвращаемых данных


JSON это, конечно, круто и его точно нужно поддерживать с самого начала. Но с развитием системы должна быть возможность выбора формата выходных данных, например, AtomPub.

Причем, формат вывода должен определяться нативно, через заголовок HTTP Accept, а не через лишние конструкции вида ?format=xml.

Перегружай семантикой стандартные методы HTTP-протокола


Большинство виденных мною REST API отображают основные стандартные методы HTTP (POST/GET/PUT/DELETE) в соответствующие CRUD-методы (create/retrieve/update/delete). Это не очень хорошая идея, потому что одни ресурсы могут использовать паттерн POST-как-создатель-связанных-ресурсов, а другие могут использовать POST-как-редактирование для обратной совместимости с HTML-формами. Любая форма должна быть допустима.

Более того, такие системы не допускают использование расширенных HTTP-методов. WebDAV определяет несколько полезных методов, так же новый метод HTTP PATCH уже официально часть стандарта. Ни кто не говорит о том что вы должны ограничивать себя четырьмя основными методами HTTP, речь идет лишь о том чтобы эти методы широко поддерживались, т.к. нестандартные HTTP-запросы могут просто игнорироваться веб-сервером.

Используй индексы для определения связей между ресурсами


Итак, вы хотите чтобы один ресурс ссылался на другой. Например, PhotoAlbum ссылается на объекты Photo. Обычно вы делаете это как-то так:

{
    'album': 'whatever',
    'photos': [1, 2, 3, 4]
}

Просто указываете ID объектов, да? Печально, но это означает что клиентский код должен «просто знать» о том как конструировать URL на ресурсы Photo. Это так же означает что клиентский и серверный код становятся зависимыми. Как бонус, вы получаете все проблемы, связанные с несовместимостью API и клиентского кода. Почти все API на нашей планете допускают эту ошибку, поэтому малейшее изменение в формате URL автоматически сломает всех клиентов вашего API.

Просто используйте готовые URL:

{
    'photos': ['http//example.com/ph/1', ...]
}

Или, если волосы встают дыбом от дублирования, сообщите формат URL:

{
    'photos': [1, 2, 3],
    'photo_uri_template': 'http://example.com/ph/{id}'
}

Жестко привяжи REST API к своему приложению


Любой большой API должен иметь выделенный сервер (серверы) для своей работы: характеристики производительности и факторы, от которых они зависят, так отличаются от обычных веб-приложений, что для больших API требуются отдельные, специальным образом настроенные, серверы. Это факт. Более того, если API-сервер падает, это не должно влиять на общедоступный веб-сайт.

Всем успехов в проектировании API!
Поделиться с друзьями
-->

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


  1. VolCh
    07.04.2017 11:12
    +3

    Перегружай семантикой стандартные методы HTTP-протокола

    То ли перевод неудачный, то ли что. Но в общем и в целом REST API over HTTP не обязан использовать семантику HTTP. Она ориентирована на такую работу с ресурсами, которая предполагает что клиент задает в целом состояние ресурса, в которое он перейдёт после отработки команды.


    1. servancho
      07.04.2017 14:27

      При желании можно впихнуть всю логику в POST, ссылаясь на ограниченную поддержку других методов HTTP, те перегрузить семантикой метод POST.


      1. VolCh
        07.04.2017 15:30
        -1

        Следует различать REST API over HTTP и HTTP REST API. Первое использует HTTP только в качестве транспорта, а второе должно соблюдать семантику HTTP.


  1. Igreh
    07.04.2017 14:09
    +1

    {
        'photos': [1, 2, 3],
        'photo_uri_template': 'http://example.com/ph/{id}'
    }
    

    В таком случае клиент должен будет сходить на сервер за каждой фоткой, чтобы отрендерить, например, галерею.
    Вариант вернуть сразу, что-то вроде:
    {
        'photos': [
            {
               'id': 1,
               'alt': 'text',
               'uri': 'https://domain.net/path/img.png'
            },
            {...}, 
            ....
        ],
    
    }
    

    не true REST way?


    1. servancho
      07.04.2017 14:31
      +1

      Это первый указанный вариант решения проблемы «Просто используйте готовые URL». Вы дополнили URL метаданными, тут всё ок.


    1. Cromathaar
      07.04.2017 14:39
      +1

      Пример показывает принцип, а не демонстрирует самое эффективное решение. Замените photos на tracks, если вас так коробит.


    1. doctrine
      08.04.2017 10:08

      Также можно использовать HATEOAS


  1. wedoca
    07.04.2017 14:28

    Или, если волосы встают дыбом от дублирования, сообщите формат URL

    Спасибо за идею.


  1. monolithed
    07.04.2017 15:16
    -1

    Пусть твой API поддерживает только один формат возвращаемых данных

    JSON это, конечно, круто и его точно нужно поддерживать с самого начала. Но с развитием системы должна быть возможность выбора формата выходных данных, например, AtomPub.

    Какое-то противоречие.

    Причем, формат вывода должен определяться нативно, через заголовок HTTP Accept, а не через лишние конструкции вида ?format=xml.

    Ну и какой профит это дает?


    1. parotikov
      07.04.2017 20:44

      Пусть твой API поддерживает только один формат возвращаемых данных

      Это же сарказм, вредные советы же. Это следует читать как Поддерживайте разные форматы.

      Про формат в заголовке:
      можно ведь и токен, и данные из формы передавать гет-параметрами, но зачем?
      Хотя в урле конечно нагляднее, но в заголовках тоже ок.


      1. servancho
        07.04.2017 20:45

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


      1. sshikov
        07.04.2017 20:53

        Эх. Вот только сегодня смотрел на REST API от ArcGIS. Параметр f=json указывает формат ответа, а на выходе Content-type: text/plain. Хм. Видимо они вашу статью еще не прочли :)


    1. servancho
      07.04.2017 20:46

      Это вредные советы ;)


  1. vintage
    08.04.2017 17:59

    Объединяй модели и ресурсы

    В этом нет ничего плохого. Наоборот, получается весьма гибко и лаконично. Для агрегационных ресурсов просто создаются виртуальные модели (у которых все свойства — динамически вычисляемые). Например, модель "полнотекстовый поиск", имеющая свойства "поисковой запрос" и "список найденных моделей". Запрашивать соответственно можно не модели по отдельности, но и сразу связанные модели. При этом выдача всегда должна быть в нормализованном виде, чтобы не было комбинаторного взрыва данных. Пример ответа на запрос /search/query=api?fetch=query,found(type,name,title,author(name),articles!count):


    {
        '/search/query=api' : {
            'query' : 'api',
            'found' : [ '/user=jin' , '/article=best-api' ]
        },
        '/user=jin' : {
            'type' : 'user' ,
            'name' : 'Nin Jin' ,
            'articles!count' : 1
        },
        '/article=best-api' : {
            'type' : 'article' ,
            'title' : 'Best API ever' ,
            'author' : '/user=jin'
        }
    }

    Жестко зашивай конкретную подсистему контроля доступа в код API

    Правила, зашитые в коде, более надёжные и легко могут быть изменены простым обновлением кода. Правила же вынесенные, например, в БД, напротив, требуют очень осторожного обращения при изменении. Например, если пользователь исключён из группы "А", то у него надо убрать доступ к ресурсу "Б", а в ресурсе "В" ему нужно заблокировать доступ к полю "Г", но только если он не является его автором. В общем, чтобы не превращать API в маленький ад, стоит все правила описывать кодом, вынося в БД лишь по мере необходимости. Что касается аутентификации, то стоит всё делать через токены. Просто некоторые токены можно получать по идентификатору, некоторые по логину-паролю, а некоторые ещё как-нибудь.


    Причем, формат вывода должен определяться нативно, через заголовок HTTP Accept, а не через лишние конструкции вида ?format=xml.

    У дополнительного параметра есть следующие преимущества:


    1. Клиенту не надо париться с установкой заголовка Accept.
    2. Серверу не надо париться с установкой заголовка Vary.
    3. При отладке, перейдя по ссылке ты получаешь ровно то, что и приложение.

    Content Negotiation хорош, когда у нас есть куча пользовательских агентов, каждый из которых понимает разные форматы и серверу нужно уметь работать с каждым из них. В случае API всё наоборот — клиентское приложение разрабатывается под конкретную выдачу сервера, а в этих условиях лучше всего, если для получения данных достаточно просто скопипастить ссылку в код. Поэтому в ссылку имеет смысл зашивать и формат выдачи и версию API.


    Просто используйте готовые URL

    Но лучше всё же использовать не абсолютные урлы, а относительные, потому что:


    1. Они занимают меньше места, а в некоторых случаях гораздо меньше места.
    2. Их проще формировать. не надо приклеивать правильную схему, хост, порт и другие параметры урла.
    3. Ваше API по тем или иным причинам может быть доступно по разным доменным именам и вы не всегда можете получить именно то имя, по которому обращается клиент.
    4. Меньше визуального шума при отладке.
    5. Эти относительные ссылки можно хранить в БД в качестве человекопонятных глобальных уникальных идентификаторов не привязанных к конкретному доменному имени.

    Жестко привяжи REST API к своему приложению

    Деление на API и APP не имеет особого смысла. Лучше иметь кластер кроссфункциональных нод, который будет адекватно реагировать на изменения нагрузки по разным запросам, чем поддерживать несколько отдельных приложений, которые по хорошему должны развиваться синхронно. Ну и, если на клиенте у вас SPA, то зачем вам что-то кроме API? :-)


    1. VolCh
      09.04.2017 16:41

      Деление на API и APP не имеет особого смысла.

      В случае SOA вполне имеет. App-серверы имеют один-единственный серверный API (в том числе с собственным протоколом/форматом), работающий исключительно в рамках ответственности сервиса, а API-серверы предоставляют множество клиентских API к множеству сервисов, в том числе агрегируя данные для клиентов с нескольких сервисов в один ответ.


    1. servancho
      11.04.2017 11:24

      Для агрегационных ресурсов просто создаются виртуальные модели (у которых все свойства — динамически вычисляемые).

      Ровно об этом и написано ;)
      «Тут же полезно вспомнить паттерн фасад — ресурс это фасад для моделей, а не конкретная модель»

      Правила же вынесенные, например, в БД, напротив, требуют очень осторожного обращения при изменении.

      Немножко однобокий взгляд «на вынос контроля доступа». Контроль доступа можно агрегировать в контроллеры на уровне кода. Описать в виде декораторов или возвращаемых значений шаблонных методов.

      У дополнительного параметра есть следующие преимущества

      Установка хедера Accept и передача параметра ?format=xxx равнозначны по объему кода. Версия API обычно зашивается в сам URL, а не в параметры GET-запроса.

      Деление на API и APP не имеет особого смысла. Лучше иметь кластер кроссфункциональных нод, который будет адекватно реагировать на изменения нагрузки по разным запросам,

      Расскажите это инженерам Twitter :)


      1. vintage
        11.04.2017 11:40
        +1

        Ровно об этом и написано ;)

        Не совсем. "Полнотекстовый поиск" — не является фасадом для других моделей. Он делает запрос к базе и возвращает список идентификаторов. Он сам по себе является моделью и как любая модель доступен через REST, WS, SOAP и прочие протоколы.


        Немножко однобокий взгляд «на вынос контроля доступа». Контроль доступа можно агрегировать в контроллеры на уровне кода. Описать в виде декораторов или возвращаемых значений шаблонных методов.

        Я не понял, вы со мной спорите или соглашаетесь? :-)


        Установка хедера Accept и передача параметра ?format=xxx равнозначны по объему кода.

        Не равнозначны по объёму вручную написанного кода. Урл просто копипастится и всё. Установку Accept необходимо специально прописать руками.


        Версия API обычно зашивается в сам URL, а не в параметры GET-запроса.

        Совершенно не важно в какую часть ссылки её зашивать.


        Расскажите это инженерам Twitter :)

        Тоже мне авторитет.


  1. babylon
    08.04.2017 18:45

    @vintage не все умеют агрегатировать запросы в объект. Это отдельная техника. Но это правильная техника. Можно спорить о форме записи. Для однородных запросов я предпочитаю загонять поля в массив, а их названия выносить в массив заголовков.


    1. vintage
      08.04.2017 18:50

      Для однородных запросов я предпочитаю загонять поля в массив, а их названия выносить в массив заголовков.

      Боюсь без примера не могу понять о чём это вы.