Рассказ о том, как не надо проектировать API

https://medium.com/@robertas.konarskis/how-not-to-design-restful-apis-fb4892d9057a
  • Перевод
Однажды я помогал товарищу, которому нужно было интегрировать с сайтом его клиента данные о свободном и занятом жилье из системы управления имуществом. К моей радости у этой системы было API. Но, к сожалению, устроено оно было из рук вон плохо.

image

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

Обзор ситуации


Организация, о которой идёт речь, использовала для управления жилыми помещениями систему Beds24. Сведения о том, что именно свободно, а что занято, синхронизировались с различными системами бронирования жилья (с такими, как Booking, AirBnB и другими). Организация занималась разработкой сайта и хотела, чтобы при поиске выводились лишь сведения о комнатах, свободных в указанный период времени и подходящих по вместимости. Подобная задача выглядела весьма простой, так как Beds24 предоставляет API для интеграции с другими системами. На самом же деле оказалось, что разработчики этого API допустили при его проектировании множество ошибок. Предлагаю разобрать эти ошибки, выявить конкретные проблемы и поговорить о том, как подходить к разработке API в рассматриваемых ситуациях.

Проблема №1: формат тела запроса


Так как клиенту интересны только сведения о том, свободен ли, скажем, гостиничный номер, или занят, нас интересует лишь обращение к конечной точке API /getAvailabilities. И, хотя обращение к подобному API должно приводить к получению данных о доступности комнат, это обращение, на самом деле, выглядит как POST-запрос, так как автор API решил оснастить его возможностью принимать, в виде JSON-тела запроса, фильтры. Вот список возможных параметров запроса и примеры принимаемых ими значений:

{
    "checkIn": "20151001",
    "lastNight": "20151002",
    "checkOut": "20151003",
    "roomId": "12345",
    "propId": "1234",
    "ownerId": "123",
    "numAdult": "2",
    "numChild": "0",
    "offerId": "1",
    "voucherCode": "",
    "referer": "",
    "agent": "",
    "ignoreAvail": false,
    "propIds": [
        1235,
        1236
    ],
    "roomIds": [
        12347,
        12348,
        12349
    ]
}

Пройдёмся по этому JSON-объекту и поговорим о том, что здесь не так.

  1. Даты (checkIn, lastNight и checkOut) представлены в формате YYYYMMDD. Тут нет абсолютно никакой причины для того, чтобы не использовать стандартный формат ISO 8601 (YYYY-MM-DD) при преобразовании дат в строки, так как это — широко применяемый стандарт представления дат. Он знаком многим разработчикам, именно его ожидают получить на вход многие JSON-парсеры. Кроме того, возникает ощущение, что поле lastNight является избыточным, так как тут имеется поле checkOut, которое всегда представлено датой, на один день опережающей дату, заданную в lastNight. В связи с отмеченными выше недостатками предлагаю, при проектировании подобных API, стремиться к тому, чтобы всегда использовать стандартные способы представления дат и стараться не обременять пользователей API необходимостью работы с избыточными данными.
  2. Все поля-идентификаторы, а также поля numAdult и numChild, являются числовыми, но представлены в виде строк. В данном случае для представления их в виде строк нет никакой видимой причины.
  3. Здесь можно заметить следующие пары полей: roomId и roomIds, а так же propId и propIds. Наличие полей roomId и propId является избыточным, так как и то и другое можно использовать для передачи идентификаторов. Кроме того, тут можно заметить проблему с типами. Обратите внимание на то, что поле roomId является строковым, а в массиве roomIds нужно использовать числовые значения идентификаторов. Это может привести к путанице, к проблемами с парсингом, и, кроме того, говорит о том, что на сервере некоторые операции выполняются со строками, а некоторые с числами, несмотря на то, что эти строки и числа используются для представления одних и тех же данных.

Мне хотелось бы предложить разработчикам API стараться не усложнять жизнь тем, кто этими API будет пользоваться, допуская при проектировании API ошибки, подобные вышеописанным. А именно, стоит стремиться к стандартному форматированию данных, к тому, чтобы они не были бы избыточными, к тому, чтобы для представления однородных сущностей не использовались бы разные типы данных. И не стоит всё, без разбора, представлять в виде строк.

Проблема №2: формат тела ответа


Как уже было сказано, нам интересна лишь конечная точка API /getAvailabilities. Давайте посмотрим на то, как выглядит ответ этой конечной точки, и поговорим о том, какие недочёты допущены при его формировании. Помните о том, что нас, при обращении к API, интересует список идентификаторов объектов, свободных в заданный период времени и способных вместить заданное количество людей. Ниже приведён пример тела запроса к API и пример того, что оно выдаёт в ответ на этот запрос.

Вот запрос:

{
    "checkIn": "20190501",
    "checkOut": "20190503",
    "ownerId": "25748",
    "numAdult": "2",
    "numChild": "0"
}

Вот ответ:

{
    "10328": {
        "roomId": "10328",
        "propId": "4478",
        "roomsavail": "0"
    },
    "13219": {
        "roomId": "13219",
        "propId": "5729",
        "roomsavail": "0"
    },
    "14900": {
        "roomId": "14900",
        "propId": "6779",
        "roomsavail": 1
    },
    "checkIn": "20190501",
    "lastNight": "20190502",
    "checkOut": "20190503",
    "ownerId": 25748,
    "numAdult": 2
}

Поговорим о проблемах ответа.

  1. В теле ответа свойства ownerId и numAdult внезапно стали числами. А в запросе нужно было указывать их в виде строк.
  2. Список объектов недвижимости представлен в виде свойств объекта, ключами которых являются идентификаторы комнат (roomId). Логично было бы ожидать того, что подобные данные выводились бы в виде массива. Для нас это означает, что для того чтобы получить список доступных комнат, нужно перебрать весь объект, проверяя при этом наличие у вложенных в него объектов определённых свойств, вроде roomsavail, и не обращая внимания на что-то вроде checkIn и lastNight. Затем нужно было бы проверить значение свойства roomsavail, и, если оно больше 0, можно было бы сделать вывод о том, что соответствующий объект доступен для бронирования. А теперь давайте присмотримся к свойству roomsavail. Вот какие варианты его представления встречаются в теле ответа: "roomsavail": "0" и "roomsavail": 1. Видите закономерность? Если комнаты заняты — значение свойства представлено строкой. Если свободны — оно превращается в число. Это способно привести к множеству проблем в языках, строго относящихся к типам данных, так как в них одно и то же свойство не должно принимать значения разных типов. В связи с вышесказанным мне хотелось бы предложить разработчикам использовать массивы JSON-объектов для представления неких наборов данных, а не применять для этой цели неудобные конструкции в виде пар ключ-значение, подобные той, что мы тут рассматриваем. Кроме того, нужно следить за тем, чтобы поля однородных объектов не содержали бы данные разных типов. Правильно отформатированный ответ сервера мог бы выглядеть так, как показано ниже. Обратите внимание и на то, что при представлении данных в таком виде сведения о комнатах не содержат дублирующихся данных.

{
    "properties": [
        {
            "id": 4478,
            "rooms": [
                {
                    "id": 12328,
                    "available": false
                }
            ]
        },
        {
            "id": 5729,
            "rooms": [
                {
                    "id": 13219,
                    "available": false
                }
            ]
        },
        {
            "id": 6779,
            "rooms": [
                {
                    "id": 14900,
                    "available": true
                }
            ]
        }
    ],
    "checkIn": "2019-05-01",
    "lastNight": "2019-05-02",
    "checkOut": "2019-05-03",
    "ownerId": 25748,
    "numAdult": 2
}

Проблема №3: обработка ошибок


Вот как организована обработка ошибок в рассматриваемом здесь API: на все запросы система отправляет ответы с кодом 200 — даже в том случае, если произошла ошибка. Это означает, что единственный способ отличить нормальный ответ от ответа с сообщением об ошибке заключается в разборе тела ответа и в проверке наличия в нём полей error или errorCode. В API предусмотрены лишь следующие 6 кодов ошибок.


Коды ошибок API Beds24

Предлагаю всем, кто это читает, постараться не возвращать ответ с кодом 200 (успешная обработка запроса) в том случае, если при обработке запроса что-то пошло не так. Пойти на такой шаг можно лишь в том случае, если это предусмотрено фреймворком, на базе которого вы разрабатываете API. Возврат адекватных кодов ответов позволяет клиентам API заранее знать о том, нужно ли им парсить тело ответа или нет, и о том, как именно это делать (то есть — разбирать ли обычный ответ сервера или объект ошибки).

В нашем случае улучшить API в этом направлении можно двумя способами: можно либо предусмотреть особый HTTP-код в диапазоне 400-499 для каждой из 6 возможных ошибок (лучше всего поступить именно так), либо возвращать, при возникновении ошибки, код 500, что позволит клиенту, по меньшей мере, знать перед разбором тела ответа о том, что оно содержит сведения об ошибке.

Проблема №4: «инструкции»


Ниже приведены «инструкции» по использованию API из документации проекта:

Пожалуйста изучите следующие инструкции при использовании API.

  1. Обращения к API следует проектировать так, чтобы в ходе их выполнения приходилось бы отправлять и принимать минимальный объём данных.
  2. Обращения к API выполняются по одному за раз. Необходимо дождаться выполнения очередного обращения к API прежде чем выполнять следующее обращение.
  3. Если нужно выполнить несколько обращений к API, между ними следует предусмотреть наличие паузы длительностью несколько секунд.
  4. Вызовы API нужно выполнять не слишком часто, поддерживая уровень обращений на минимальном уровне, необходимом для решения задач клиента.
  5. Чрезмерное использование API в пределах 5-минутного периода приведёт к блокировке вашей учётной записи без дополнительных уведомлений.
  6. Мы оставляем за собой право блокировать доступ к системе клиентам, которые, по нашему мнению, чрезмерно используют API. Делается это по нашему усмотрению и без дополнительных уведомлений.

В то время как пункты 1 и 4 выглядят вполне обоснованными, с другими пунктами этой инструкции я согласиться не могу. Рассмотрим их.

  1. Пункт №2. Если вы разрабатываете REST API, то предполагается, что это будет API, не зависящее от состояния. Независимость обращений к API от предыдущих обращений к нему — это одна из причин того, что технология REST нашла широкое применение в облачных приложениях. Если некий модуль системы не поддерживает состояние, его, в случае ошибки, можно легко развернуть повторно. Системы, основанные на подобных модулях, легко масштабируются при изменении нагрузки на них. При проектировании RESTful API стоит следить за тем, чтобы это было API, не зависящее от состояния, и чтобы тем, кто его использует, не приходилось бы беспокоиться о чём-то вроде выполнения только одного запроса за раз.
  2. Пункт №3. Этот пункт выглядит довольно странно и неоднозначно. Я не могу понять причину, по которой был написан этот пункт инструкции, но у меня возникает ощущение, что он говорит нам о том, что в процессе обработки запроса система выполняет некие действия, и, если её при этом «отвлечь» ещё одним запросом, отправленным не вовремя, это может нарушить её работу. Кроме того, то, что автор руководства говорит о «нескольких секундах», не позволяет узнать точной длительности паузы, которую нужно выдержать между последовательными запросами.
  3. Пункты №5 и №6. Тут говорится о «чрезмерном использовании API», но никаких критериев «чрезмерного использования» не приводится. Может, это 10 запросов в секунду? А может — 1? Кроме того, некоторые веб-проекты могут иметь огромные объёмы трафика. Если без каких-то адекватных причин и без уведомлений закрывать им доступ к нужным им API, их администраторы, наверняка, от использования таких API откажутся. Если вам доведётся писать подобные инструкции — используйте в них чёткие формулировки и ставьте себя на место пользователей, которым придётся работать с вашей системой, руководствуясь вашими инструкциями.

Проблема №5: документация


Вот как выглядит документация к API.


Документация к API Beds24

Единственная проблема этой документации — её внешний вид. Она выглядела бы гораздо лучше, если бы её хорошо отформатировали. Специально для того, чтобы показать возможный внешний вид подобной документации, я, воспользовавшись Dillinger, и потратив на это меньше двух минут, сделал следующий её вариант. На мой взгляд, он выглядит гораздо лучше вышеприведённого.


Улучшенный вариант документации

Для создания подобных материалов рекомендуется пользоваться специальными инструментами. Если речь идёт о простых документах, похожих на вышеописанный, то для их оформления вполне достаточно чего-то вроде обычного markdown-файла. Если документация устроена сложнее, то для её оформления лучше всего воспользоваться инструментами наподобие Swagger или Apiary.

Кстати, если сами хотите взглянуть на документацию к API Beds24 — загляните сюда.

Проблема №6: безопасность


В документации ко всем конечным точкам API сказано следующее:

Для использования этих функций должен быть разрешён доступ к API. Это делается в меню SETTINGS → ACCOUNT → ACCOUNT ACCESS.

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

Большинство JSON-методов требуют ключ API для доступа к учётной записи. Ключ доступа к API можно установить, воспользовавшись меню SETTINGS → ACCOUNT → ACCOUNT ACCESS.

В дополнение к непонятному разъяснению вопросов аутентификации оказывается, что ключ для доступа к API пользователь должен создавать самостоятельно (делается это, кстати, путём ручного заполнения соответствующего поля, какие-то средства для автоматического создания ключей не предусмотрены). Длина ключа должна быть в пределах от 16 до 64 символов. Если позволить пользователям самостоятельно создавать ключи для доступа к API, это может привести к появлению весьма небезопасных ключей, которые можно легко подобрать. В подобной ситуации возможны и проблемы, связанные с содержимым ключей, так как в поле для ключа можно ввести всё что угодно. В худшем случае это может привести к атаке на сервис методом SQL-инъекции или к чему-то подобному. При проектировании API не позволяйте пользователям создавать ключи для доступа к API самостоятельно. Вместо этого генерируйте для них ключи автоматически. Пользователь не должен иметь возможности изменить содержимое такого ключа, но, при необходимости, он должен иметь возможность сгенерировать новый ключ, признав старый недействительным.

В случае с запросами, для выполнения которых нужна аутентификация, мы видим другую проблему. Она заключается в том, что токен аутентификации должен быть отправлен в виде части тела запроса. Вот как это описано в документации.


Пример аутентификации в API Beds24

Если токен аутентификации передаётся в теле запроса — это означает, что серверу, прежде чем он доберётся до ключа, нужно будет разобрать тело запроса. После этого он извлекает ключ, выполняет аутентификацию, а затем уже решает — что ему делать с запросом — выполнять его или нет. Если аутентификация удалась — то сервер не будет подвержен дополнительной нагрузке, так как в таком случае тело запроса всё равно пришлось бы разбирать. А вот если аутентифицировать запрос не получилось, то ценное процессорное время будет потрачено на разбор тела запроса впустую. Лучше будет отправлять токен аутентификации в заголовке запроса, используя что-то наподобие схемы аутентификации Bearer. При таком подходе серверу понадобится разбирать тело запроса лишь в том случае, если аутентификация окажется успешной. Ещё одной причиной, по которой для аутентификации рекомендуется использовать стандартную схему наподобие Bearer, является тот факт, что с подобными схемами знакомо большинство разработчиков.

Проблема №7: производительность


Эта проблема в моём списке последняя, но это не умаляет её важности. Дело в том, что на выполнение запроса к рассматриваемому API уходит немного больше секунды. В современных приложениях такие задержки могут оказаться недопустимыми. Собственно говоря, тут можно посоветовать всем, кто занимается разработкой API, не забывать о производительности.

Итоги


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

Поэтому я хотел бы попросить всех, кто проектирует API, думать о том, как с ним будут работать пользователи их сервисов. Следите за тем, чтобы документация к API полно описывала бы их возможности, чтобы она была бы понятной и хорошо оформленной. Контролируйте именование сущностей, обращайте внимание на то, чтобы данные, которые выдаёт или принимает ваше API, были бы чётко структурированными, чтобы с ними было бы легко и удобно работать. Кроме того, не забывайте о безопасности и о правильной обработке ошибок. Если при проектировании API принять во внимание всё то, о чём мы говорили, тогда для работы с ним не понадобится писать нечто вроде тех странных «инструкций», которые мы обсуждали выше.

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

Уважаемые читатели! Встречались ли вам некачественно спроектированные API?

RUVDS.com
1042,00
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией

Комментарии 11

    +7

    Вы в статье упоминаете RESTful API, но у Beds24 оно не RESTful, a RPC.
    Потому подход REST к ним особенно то и не применим.

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


      в предмете обсуждения мало реста, а если реста мало — то это вовсе не рест )

      надоело
      следующий проект апи сделаю без rest-ориентации, надоело, чесслово:
      — километровая документация, /users (post, get) /users/ID (get, put, delete) /users/ID/photo (get, put, delete) и т.п. проиходится описывать запрос, ответ, возможные ошибки (валидация, и т.п.), закодить, описать доку (окей, сгенерировать можно)… чтото пытался переиспользовать (одинаковые запросы\ответы) — всеравно в проекте средней величины документация распухает, а еще удобно иметь возможность тестовые запросы отправлять и т.д. и т.п… ад крс
      — десятки, сотни ендпоинтов… которые нужно «адекватно» назвать, придерживаться правил нейминга и лучших практик, и т.п., за каждым ендпоинтом следить и возвращать «адекватный» http status code… ад
      — частая необходимость компоновать несколько существующих ендпоинтов в 1 (економить на запросах\трафике, скорость ответа и т.п., да и просто для фронта\отладки удобней пользоваться и имплементировать)… тогда начинается боль куда ендпоинт припарковать одновременно относящийся к /users /announcements /images и конкретно к ниодному (только часть логики из каждого с предыдущих)… ад
      — фильтры… по ресту это GET, оно и правда так, но проблема — длинна запроса (ключей может быть много, значений еще больше (длинна)… и визуально большой фильтр не так просто разобрать, особенно при отладке…
      — и так дале… тоесть, часто, рест гдето в средине проекта все сложнее поддерживать рестом…

      графкл, казалось бы, мощная замена ресту, но слишком много всего описать и связать, чтобы адекватно работало… хоть в преспективе оправдывается… но не без проблем… сложность системы в разы выше сложности бизнес-логики…

      след. апи-проект делаю по тупому и лениво:
      — один эндпоинт, он есть корень: /
      — один метод, только POST
      — запрос в виде:
      {
      "schema":"user", 
      "action":"create",
      "request": {},
      }

      — ответ в виде:
      {
      "schema":"user", 
      "action":"create",
      "response": {},
      }


      схема user описывает в себе возможные action и их запросы\ответы, все в одном месте, у меня голова не болит:
      — как назвать и куда припарковать ендпоинт
      — нет длинных урлов
      — удобно строить структуру проекта (тупо 1 примитивный шаблон на все)
      — граница отвественности (схема делает то что делает, нет пересечений)
      — легко документировать

      както так… надоел уже этот рест, честн.слово… сил нет )
        –1
        GraphQL описали)
          +2
          Кажется вы изобрели JSON-RPC.
          0
          Проблема №4 обычно возникает, когда инструкцию пишет человек с уклоном в гуманитарное мышление — никакой конкретики, сплошные приближённые понятия. Не удивлюсь, если это API писал такой же разработчик.

          Однако, что касается замечаний к этому пункту, тут тоже не всё последовательно. Автор пишет, что RESTfull API не должно зависеть от предыдущего вызова, однако это всё-таки лукавство. API в абсолютном большинстве случаев подразумевает не только вывод данных, но и ввод. Входные данные по определнию способны изменить состояние системы, иначе зачем они вообще нужны? Если следовать этой идеологии до конца, следовало бы делать два API, одно полностью RESTfull, служащее только для получения информации о системе, и другое — для ввода данных. Однако, полагаю, два API вместо одного, автора обрадовали бы ещё меньше, хотя идеология бы и выполнялась.
            0

            Полагаю насчет состояния имелось в виду чуть чуть другое.
            К примеру у нас есть: /post/1/comments
            Получить все комментарии к публикации с ID=1
            При повторном запросе мы так же ожидаем получить все комментарии, но API запомнив нас, может вернуть только новые комментарии с момента последнего запроса.
            Если мы захотим только новые комментарии, то мы сами должны об этом сказать, обратившись к примеру: /post/1/comments/2453
            Получить комментарии опубликованные после комментария с ID=2453

              0
              Ну, описанное вами никакого отношения к REST не имеет, насколько я могу судить.

              Так же криво можно и RESTfull API написать.
            0
            apidocs.ucs.ru/doku.php:ru:start
            Вот вам пример как делать не надо
              0
              Меня терзают смутные сомнения, что все эти лишние поля оставлены для обратной совместимости. Так что я бы жаловался на отсутствие версий API во всей этой конструкции, но никак не на это
                0
                Возможно это самонадеянно, но могу с большой долей уверенности сказать, откуда взялось такое апи и почему оно такое.

                Есть некоторое внутреннее приложение, для которого был разработан некоторый апи, отвечающий его потребностям. В какой-то момент понадобилось сделать внешний доступ к данным системы.
                Вариант1: давайте разработаем новый апи (+хх человеко-часов).
                Вариант2: давайте откроем доступ к имеющемуся или его части (+yy человеко-часов).
                Я думаю не нужно объяснять что xx>>yy.
                Так как система коммерческая, очевидно, что главным фактором была стоимость разработки. Откуда и был сделан понятный выбор.

                Например формат ответа мне вполне понятен — если id комнат содержаться отдельно (а формат запроса на это явно намекает), то дальнейший доступ через response[roomId].roomsavail существенно удобнее и в написани и в прочтении и в исполнении, чем попытка аналогичного доступа в «улучшенном» виде ответа. Я попытался это сделать однострочником, но понял, что это выходит полным провалом. Даже через цикл это выглядит странновато. Да, можно делать трансформацию ответа, можно оформить доступ через функцию. Но зачем. АПИ очевидно дает тот ответ, который нужен и удобен системе в которой он работает.
                  0
                  По ссылке которую вы даёте на стандарт ISO 8601 написано:
                  "
                  The hyphens can be omitted if compactness of the representation is more important than human readability, for example as in

                  19950204
                  "
                  Вызов АПИ будет принимать сервер, ему человекочитаемый вид данных до лампочки, а компактность — может пригодиться, запись даты и времени без знаков препинания это в рамках стандарта ISO 8601.

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

                  В целом хорошая статья. Спасибо.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое