Раскрываем 12 кейсов проектирования спецификации REST API из практики red_mad_robot, которые помогут сэкономить время для разработки. А также объясняем, почему стоит следовать подходу contract first — писать спецификацию прежде кода.

Статей, объясняющих основы архитектурного стиля REST, больше, чем символов в этом тексте. Самих принципов всего шесть, но они дают большой простор для реализации задач разными способами. В этой статье ведущий backend‑разработчик red_mad_robot Серёжа Ретивых делится дополнительными правилами, которые мы выработали и применяем у себя в red_mad_robot на практике. Начнём с общих терминов, а если вам всё это знакомо, перепрыгивайте сразу в блок «Рекомендации проектирования для REST API».


Что такое REST API и зачем он нужен

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

Сервер предоставляет клиенту API (англ. Application programming interface) — это способ взаимодействия с собой, а для его описания служат контракты. Они однозначно интерпретируют передаваемые данные, а также дают возможность понять, что сервер вообще умеет делать.

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

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

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

Спецификации тоже можно писать и форматировать по‑разному. Поэтому по мере развития выработался стандарт OpenAPI — самый распространённый и понятный формат. Мы в red_mad_robot используем его.

Почему стоит писать спецификацию прежде кода

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

Мы в red_mad_robot стараемся писать спецификацию прежде кода, и вот почему.

Скорость разработки

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

Моковый сервер

Из спецификации можно автоматически генерировать актуальный моковый сервер и делать жизнь потребителей API ещё лучше.

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

Кастомизация API

Проектирование API руками не ограничено скудной функциональностью генератора и позволяет сделать его по-настоящему удобным для конкретной задачи.

Гибкость процесса

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

Дополнительный контроль схемы данных

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

Представим, что есть книга с идентификатором и автором. Если автор — просто строка, вложенных данных нет. А если автор, в свою очередь, имеет идентификатор, ФИО и возраст, и мы их также передаем внутри книги, то автор — вложенные данные.

Пример первый. Есть простой GET-запрос профиля пользователя, оценённый как мэппинг данных из одной таблицы базы данных в JSON-объект. Мы публикуем спецификацию, Frontend приходит с запросом — в дизайне ещё есть поле «Бонусы». И вот нам уже нужно делать интеграцию или асинхронно подготавливать данные.

Пример второй. Разработчики не любят писать лишний код. Если ранее был реализован сериализатор для модели А с вложенным объектом В, то дальше он везде и будет использоваться. Необходимости в В для конкретного эндпоинта может и не быть, но будет лишний JOIN в хорошем случае и SELECT в плохом.

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

Рекомендации проектирования для REST API

Итак, сначала спецификация — потом реализация. Вот правила, которым мы следуем помимо общепринятых.

1. Если из эндпоинта нужно вернуть данные, всегда оборачивайте их в объект

Эндпоинт (англ. endpoint — конечная точка) — метод вашего сервиса, предоставляемый для использования. Выполняет конкретную задачу, принимает параметры и данные, возвращает данные.

Мотивация:

  • эндпоинт проще расширять (например, добавить поля для постраничной навигации);

  • «фронту» иногда проще парсить ответ, особенно если возвращается массив;

  • субъективно проще воспринимается.

JSON (JavaScript Object Notation) — формат данных, который выглядит как объект в JavaScript, отсюда и название.

Пример:

[“a“, “b“] =>
{“chars“: [“a“, “b“]}.

2. При встраивании данных отдавать предпочтение объектам, а не плоской (одномерной) структуре полей с префиксами

Как и в предыдущем пункте — проще расширять, хотя здесь не будет несовместимых изменений. Тем не менее иметь рядом user_id и объект user: {«id»: 1, «age»: 20} — некрасиво, потому что это одни и те же данные, только расположенные в разных полях. Но если мы понимаем, что указанный ID от сущности во внешней системе, или мы уверены, что обогащение данными будет происходить не в этом запросе, тогда логично оставить поле в плоской структуре.

Пример:

{“book“: {“id“: 123, “author_id“: 1}} =>
{“book“: {“id“: 123, “author“: {“id“: 1}}}

3. Уделить больше внимания ошибкам. Коды ошибок (не http) явно указывать в описании

Ошибки бизнес-логики плохо сопоставляются с http-кодами. Особенно код 400: в эту ошибку обычно помещается масса кейсов — и неправильно введённый пользователем код OTP, и ошибки валидации формы, и ошибки операций, недоступных пользователю по бизнес-процессу. Без явного описания только по коду о них невозможно узнать.

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

Мы договорились использовать следующий формат ошибок:

“error”: {
    “code”: “символьный или числовой код ошибки, в идеале выносится в справочник”, 
    “message”: “понятное сообщение для пользователя в UI”,     
    “err»: “сама ошибка, подробности в зависимости от среды”
}

Возможно добавление поля с ошибками валидации дополнительно. Ошибки 401, 403, 404, 500 стоит вынести якорем и вставлять в YAML 1–4 строками.

        400:
          description: |
            Коды ошибок:
                * AuthWrongCodeError - ошибка ввода кода
                * AuthExpiredCodeError - срок действия кода
                * AuthCodeWasNotSentError - код не был получен пользователем
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIError"

4. Не выносите в схемы (/components/schemas) эквивалент модели в базе данных. Скорее всего, на каждый запрос будет своя схема

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

Лучше иметь отдельные схемы UserCreate, UserList и UserProfile под свои запросы.

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

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

Есть альтернативный вариант с ModelShort и ModelDetails схемами. Но велик риск, что рано или поздно потребуется схема ModelDetailsWithExtra и ей подобные, а пересечение моделей в разных запросах начнёт замедлять разработку (или выполнение запроса за счёт подготовки лишних данных).

5. Минимизируйте вложенность в URL

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

Пример: представим, есть иерархия schools — group — student. Для просмотра списка классов в школе будем иметь путь /schools/{id}/groups, а для просмотра списка учеников путь /groups/{id}/students (а не /schools/{id}/groups/{id}/students).

6. Для сериализации массива в параметрах запроса используйте style: form и explode: false

Эти параметры OpenAPI описывают понятный формат «?colors=red,green,blue». В случае кодогенерации убедитесь, что эта часть стандарта поддерживается и код адекватно работает.

          name: types
          in: query
          description: типы клуба для фильтрации
          schema:
            type: array
            items:
              type: string
              enum:
                - gym
                - online
                - studio
                - outdoor
          style: form
          explode: false
          examples:
            oneType:
              summary: Пример для одного типа
              value: [gym] # ?type=gym
            multipleTypes:
              summary: Пример для нескольких типов
              value: [gym, outdoor] # ?types=gym,outdoor

7. Для идентификаторов используйте UUID

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

Мотивация:

  • дополнительная защита от несанкционированного доступа к объектам путём инкрементального перебора идентификаторов (если не сделана проверка на владение ресурсом);

  • дополнительная помощь при ситуации объединения данных в одну таблицу из двух источников — практически нет вероятности дублирования ключа; в спецификации для строк предусмотрен специальный format: uuid.

8. Не передавайте в строке запроса персональные данные и секреты, например токены доступа

Очевидный факт, но проговорим на всякий случай.

Исключение: допустимо в случае одноразового токена.

Мотивация: эти данные попадут в логи и могут быть получены третьими лицами. Это небезопасно.

9. Определитесь со стандартом и форматом дат

Например, используйте iso-8601 YYYY-MM-DDThh:mm:ss±hh. Подумайте про таймзоны и лучше по умолчанию сразу их используйте.

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

10. Версионируйте API

И сразу используйте префикс /api/v1.

Мотивация: использование префикса тоже не требует лишних расходов, зато проще отделить новую независимую мажорную версию. Для семантического версионирования и создания чейнджлогов в автоматическом режиме хорошо использовать Bump.sh (можно ограничиться бесплатным тарифом).

Семантическое версионирование (SemVer) — это правила именования версий библиотек для обозначения ключевых изменений. Эти сведения помогают разработчикам понять, совместима ли конкретная версия с проектами, в которых использовались предыдущие версии.

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

11. Минимизируйте ответ в JSON

Мотивация: если убрать все отступы и пробелы, по сети придётся передавать меньше данных, но читать такой массив текста сложнее. Чтобы упростить чтение, можно определять формат в соответствии с заголовком Prefer return=minimal или return=representation.

12. Заполняйте примеры полей example в спецификации

Мотивация: в некоторых ситуациях при использовании мокового сервера дефолтные нули могут мешать Frontend дальше совершать какие-то действия. И они вынуждены ждать реализацию (например, 0 на счёте не даёт возможности перейти дальше по флоу). А пример с осмысленными данными (а не только 0 и string) воспринимается понятнее, и в некоторых случаях формат строки для Frontend тоже может иметь значение.

Вывод

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

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


Кстати, у нас открыта вакансия Java-разработчика.

Над материалом работали:

  • текст — Серёжа Ретивых, Ника Черникова,

  • редактура — Виталик Балашов,

  • иллюстрации — Марина Черникова.

Делимся железной экспертизой от практик в нашем телеграм-канале red_mad_dev. А полезные видео складываем на одноимённом YouTube-канале. Присоединяйся!

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


  1. dopusteam
    00.00.0000 00:00
    +5

    Пример: {[«a», «b»]}

    А зачем возвращать невалидный JSON?

    Для идентификаторов используйте UUID

    Не думаю, что это является частью проектирования REST API


  1. XMack
    00.00.0000 00:00
    +3

    На счет ошибок: предпочитаю разделять ошибки транспорта от ошибок бизнеспроцесса.
    Т.е. если транспорт (http) доставил запрос от клиента серверу и обратно доставил ответ сервера - то http-status=200 независимо от содержимого ответа сервера.
    Мотивация:
    - путь запроса большой и 503 может отдать nginx посередине. Искать такие ошибки - боль.
    - в качестве транспорта может быть использован не только http (я видел АПИшку работающую и на UDP, и на RabbitMQ, пробовали даже принтер со сканером использовать но неудачно)


    1. PaulIsh
      00.00.0000 00:00

      А 4xx не должен nginx выдавать кроме 404. Так почему бы не совмещать этот блок с блоком json, где детально расписать ошибку?


    1. zurzeropsi
      00.00.0000 00:00
      +3

      Правильно ли я понял, что на запрос с истекшим токеном, с невалидными входными данными, с не найденным объектом, etc, вы возвращаете 200? О_о


      1. XMack
        00.00.0000 00:00
        +1

        Невалидные данные - это не проблема транспорта. Он доставил запрос и обратно ответ без ошибок со своей стороны.

        Не забываем, что мир одним только http не ограничивается. Приводя мой пример выше: мы активно использовали WinSocket для взаимодействия между СИшными либами, а там как вы понимаете нет никаких http-status. При этом эту же библиотеку применяли для межсервисного взаимодействия по http и UDP (для броадкаста, хотя позже на кроликов перевели)


      1. MasterChief
        00.00.0000 00:00
        +2

        Разумеется, т.к. ничего из этого не является частью протокола HTTP (транспортного слоя, как очень правильно отметил @XMack), а исключительно бизнес-логикой приложения. По этой причине REST в его "прямом" смысле - крайне отвратительная методология.


    1. ggo
      00.00.0000 00:00
      +1

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

      Согласитесь, поведение клиента сильно отличается в случае ответа Too many requests, и в случае ответа Unauthorized.

      А для бизнес-ошибок лучше возвращать 422 Unprocessable Entity.


    1. as_serg
      00.00.0000 00:00
      +2

      здесь как вам удобно)

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

      Я в основном имею дело с вэбом, и идея 200 ОК - {"error": "user_not_found"} контринтуитивна, и вызывает так сказать эмоции. Потому что я не ожидаю такое увидеть, и мне нужно тратить лишнее время чтобы понять подход и задумку. Потому что для меня ошибки транспорта обычно не связаны с архитектурой моего приложения, или легко локализуются (через поля c указанием сервиса-источника исключения). Но я допускаю, что могут быть задачи, где такой подход ок, наравне как и подход POST-API например. Но каждый из подходов сам в себе не регламентирует описание именно бизнесовых ошибок, тем более что они еще и по ходу пьесы всплывают частенько и не добавляются в спецификации. Повторюсь, я за полный справочник ошибок, их формальную привязку к месту возможного появления, и консистентность в выбранном подходе.


  1. funca
    00.00.0000 00:00
    +1

    Тема хорошо проработана в GraphQL. Мы используем большую часть их рекомендаций и для обычного ReST:

    https://github.com/graphql/graphql-spec/blob/main/spec/Section%207%20--%20Response.md

    https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md.


  1. funca
    00.00.0000 00:00
    +1

    Например, используйте iso-8601 YYYY-MM-DDThh:mm:ss±hh.

    OpenAPI поддерживает RFC 3339, section 5.6. Если не нужны специфичные фишки из iso8601 (периоды или интервалы дат, например), то лучше выбрать первый. А тут можно наглядно увидеть разницу https://ijmacd.github.io/rfc3339-iso8601/


  1. LaRN
    00.00.0000 00:00
    +2

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

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

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


    1. as_serg
      00.00.0000 00:00

      по умолчанию, для меня плюсы uuid перевешивают недостатки, поэтому я беру его. В случае, когда нужно оценивать ситуацию, я скорее начну рассматривать варианты использования ulid или какого-нибудь ksuid (совместимые с uuid), нежели обычного инта из сиквенса)


      1. LaRN
        00.00.0000 00:00
        +1

        Даже если int занимает 4 байта, а UUID 16? Особенно в сложных системах, где много таблиц нормализованных и много связей между ними по идентификатора. Да ещё и с индексами по этим идентификаторам.

        Тут накладные расходы на хранение идентификатором могут быть значительными, всегда ли оно того стоит?


        1. funca
          00.00.0000 00:00

          Есть популярный совет - не раскрывать в API идентификаторы из вашего хранилища. В таком случае, что показывать снаружи и что использовать внутри это два разных вопроса. Ответы на которые с течением времени могут меняться независимо друг от друга.


        1. as_serg
          00.00.0000 00:00

          в теории хранение под uuid в 4 раза большего места выглядит значительно. На практике это не имеет решающего значения, т.к. помимо идентификаторов в моделях обычно хранятся на порядок больше полезных данных и относительный вклад небольшой. я не помню проблем из-за того, что размер базы сам по себе просто "очень большой", или индекс по полю c uuid работает медленнее интового. Если прям нужно экономить место, ну или вы видите явные преимущества данного типа - используйте конечно. Но не забудьте еще учесть возникающие сайд эффекты - проверьте API на возможность прямого доступа без проверки владения ресурсом, ну и вероятность мержа этой таблицы с какой-то другой из третьего источника в будущем. По мне проще, переплатить сразу местом хранения, чем читать новости про очередную слитую базу, в данном случае из-за банального недосмотра прав доступа и инкрементальных id)