← назад к разделу

API: REST И КОНТРАКТЫ HATEOAS

Обязательно

HATEOAS: зрелость REST и гиперссылки

Java · Go · Node · Python

Ответ сам показывает доступные следующие действия, а клиент меньше знает о жёстких URL и переходах состояния.

1. Модель Ричардсона разделяет четыре уровня

1 / 5

Уровень 0 - RPC через HTTP, 1 - ресурсы, 2 - методы и статусы, 3 - гиперссылки для следующих действий.

  • JSON по HTTP ещё не означает REST.
  • Большинство API использует уровень 2.
  • HATEOAS добавляет hypermedia к ресурсам.
0 RPC1 Resources2 HTTP verbs3 HATEOAS

2. Ссылка описывает действие и отношение

2 / 5

Заказ содержит self и cancel только если отмена доступна. rel объясняет смысл ссылки, href - адрес, method - операцию.

  • Клиент находит действие по rel.
  • Недоступное действие отсутствует.
  • Сервер может изменить URL без смены rel.
{ "status": "NEW", "_links": { "self": { "href": "/orders/42" }, "cancel": { "href": "/orders/42/cancel", "method": "POST" }} }

3. Состояние ресурса меняет доступные ссылки

3 / 5

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

  • Модель переходов видна в ответе.
  • Клиент не дублирует всю матрицу состояний.
  • Сервер остаётся источником правил.
NEWself · pay · cancelDELIVEREDself · returnHypermedia показывает допустимые переходы

4. Клиенту всё ещё нужен контракт данных

4 / 5

Ссылки не объясняют поля, коды ошибок, идемпотентность и доменные правила. Поэтому OpenAPI и документация не исчезают.

  • rel становится частью стабильного контракта.
  • Форматы action-тел описываются отдельно.
  • Проблема обработки ответа не решается одним href.
Hypermedianext actions + URLsOpenAPItypes + errors + inputsРазные части одного контракта

5. Уровень 2 часто является практичным выбором

5 / 5

HATEOAS полезен сложным workflows и изменчивым переходам. Для CRUD цена новых rel, тестов и клиентской логики может не окупиться.

  • Не добавлять ссылки ради формального уровня.
  • Можно применять к отдельным сценариям.
  • Единообразие и понятные ошибки важнее ярлыка REST.
Complex workflowHATEOAS помогаетSimple CRUDREST level 2 достаточноГиперссылки - инструмент, не обязанность

Слово «REST» все понимают по-разному. Для одного это «JSON по HTTP», для другого — строгий набор правил про ресурсы и методы, а для третьего — ответы, которые сами подсказывают клиенту, что делать дальше. Чтобы не спорить, есть удобная лесенка — модель зрелости Ричардсона: она раскладывает «RESTful-ность» на четыре ступени. Верхняя ступень называется HATEOAS. Разберём, что это, зачем и почему до неё почти никто не доходит — и это нормально.

Вся идея и вся её цена помещаются в один пример: один и тот же заказ до возврата и после — и два клиента, которые читают ответ по-разному.

ответ /orders/42клиент по _linksклиент с URL в кодезаказ 42: PAID_links: self, refund,invoice — 3 ссылки[Вернуть] [Счёт]кнопки из _links[Вернуть] [Счёт]адреса зашиты в коде заказ 42: REFUNDED_links: self, invoicerefund пропала: 2 из 3[Счёт]«Вернуть» ушла сама[Вернуть] [Счёт]«Вернуть» осталась правило возвратаживёт на сервере0 правок в кодеперерисовал кнопкиPOST /orders/42/refund→ 409, возврат уже был итог: ссылок стало 2 из 3 — разницу заметил только левый клиентклиент читает _linksправила только на сервереклиент их игнорируетуровень 2 честнее и дешевле

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

Модель зрелости Ричардсона

Леонард Ричардсон предложил смотреть на API как на четыре уровня зрелости. Каждый следующий добавляет одну идею.

уровень 3 ссылки на действия уровень 2 методы и коды честные уровень 1 ресурсы есть, методы нет уровень 0 одна ручка, RPC по HTTP

Лесенка зрелости целиком: снизу одна ручка на всё, выше ресурсы, затем честные методы и коды, и только на вершине ссылки в ответе.

Узнать свой уровень просто. Если в API один адрес вроде /api, куда шлют всё подряд, обычно POST с телом «действие: получить заказ, id: 42», это уровень 0: HTTP здесь просто труба, классический RPC поверх HTTP. Если адреса есть, /orders/42, /customers/7, но отменяют заказ через GET /orders/42/cancel, а ошибка приходит с кодом 200 и текстом внутри, это уровень 1: ресурсы появились, методы используются как попало. Если GET читает, POST создаёт, PUT и PATCH меняют, DELETE удаляет, а ответы приходят с честными 200, 201, 404 и 409, это уровень 2, на нём живёт большинство API, которые называют «REST», и подробно он разобран в статье про REST API. А если ответ содержит не только данные, но и ссылки на действия, которые сейчас доступны, и клиент идёт по ним, как человек кликает по страницам сайта, а не составляет URL сам, это уровень 3, HATEOAS.

Важно: это не оценка «хорошо/плохо». Это описание, сколько идей REST вы применили. Уровень 2 — совершенно рабочая и уважаемая точка.

Что такое HATEOAS

HATEOAS — это аббревиатура от «Hypermedia As The Engine Of Application State»: гипермедиа как движок состояния приложения. Звучит громоздко, а идея простая.

Представьте сайт в браузере. Вы не запоминаете URL каждой страницы — вы видите ссылки и кнопки и кликаете по тем, что доступны сейчас. Если заказ ещё не оплачен, есть кнопка «Оплатить»; после оплаты она пропадает, зато появляется «Вернуть». Браузер не знает заранее структуру сайта — он просто идёт по ссылкам, которые ему прислал сервер.

HATEOAS предлагает то же самое, но для программы-клиента. Сервер вместе с данными присылает список доступных следующих действий в виде ссылок. Клиент не хардкодит адреса вроде /orders/42/cancel — он берёт нужную ссылку из ответа. Если действие сейчас недоступно (заказ уже отменён), ссылки просто не будет, и клиенту не нужно самому знать правила «когда можно отменять».

Пример ответа с гиперссылками

Чаще всего ссылки кладут в отдельный блок. Один из популярных форматов — HAL (Hypertext Application Language): ссылки лежат под ключом _links, а сам ответ отдают с типом application/hal+json — чтобы клиент по заголовку понял, по какому соглашению читать тело:

{
  "id": 42,
  "status": "PAID",
  "amount": 1500,
  "_links": {
    "self":   { "href": "/orders/42" },
    "refund": { "href": "/orders/42/refund" },
    "invoice":{ "href": "/orders/42/invoice" }
  }
}

Что тут происходит. Кроме самих данных заказа сервер сказал: «вот ссылка на меня самого (self), вот действие "вернуть деньги" (refund) и вот "посмотреть счёт" (invoice)». Клиенту не надо знать, как собирается URL возврата, — он берёт готовый href.

А теперь тот же заказ, но уже возвращённый:

{
  "id": 42,
  "status": "REFUNDED",
  "amount": 1500,
  "_links": {
    "self":    { "href": "/orders/42" },
    "invoice": { "href": "/orders/42/invoice" }
  }
}

Ссылки refund больше нет — возвращать нечего. Клиент, который «просто рисует кнопки по ссылкам», автоматически спрячет кнопку возврата. Правило «когда можно вернуть деньги» осталось на сервере, клиенту его дублировать не пришлось.

Зачем это нужно

У HATEOAS есть два честных плюса.

Слабая связанность клиента и сервера. Клиент не зашивает у себя карту URL и правила «когда какое действие доступно». Сервер может поменять адрес возврата с /orders/42/refund на что-то другое — клиент не сломается, потому что берёт ссылку из ответа, а не собирает её сам. Логика «что доступно» живёт в одном месте — на сервере.

Самодокументируемость. Ответ сам показывает, что с ресурсом можно делать дальше. В идеале клиент способен «исследовать» API, переходя по ссылкам от корня, почти как человек ходит по сайту, — не заглядывая постоянно в отдельную документацию.

Почему на практике доходят редко

Теперь честно про минусы — из-за них уровень 3 остаётся скорее теорией.

Сложнее серверу. Каждый ответ надо обвешивать ссылками, считать, какие действия сейчас доступны, поддерживать формат вроде HAL. Это заметно больше кода, чем просто отдать данные.

Сложнее клиенту — если он вообще хочет это использовать. Красивая идея «клиент ходит по ссылкам и ничего не хардкодит» требует умного клиента, который умеет находить действия по их именам и реагировать на их появление/исчезновение. На деле почти все клиенты всё равно знают структуру API заранее и просто игнорируют блок _links — тогда вся дополнительная работа сервера пропадает впустую.

Мало инструментов и привычки. Вокруг уровня 2 выстроена вся экосистема: генераторы клиентов, документация OpenAPI, тестовые инструменты. Полноценный HATEOAS-клиент, который динамически ходит по ссылкам, поддерживают единицы, и у команд нет устоявшихся практик.

Итог трезвый: большинство API, которые называют «REST», на самом деле остаются на уровне 2 — ресурсы плюс правильные HTTP-методы. И это совершенно нормально. HATEOAS полезно знать как идею и как верхнюю планку модели, но тащить его в каждый проект не нужно.

Где это применяется

Отдельные элементы HATEOAS встречаются чаще, чем полный уровень 3. Обычно берут не всё, а самое полезное:

  • Ссылки на связанные ресурсы. Отдать вместе с заказом ссылку на клиента и на счёт — дёшево и удобно, даже если остальное осталось на уровне 2.
  • Пагинация. Классический удачный случай: сервер присылает ссылки next и prev, и клиенту не надо самому клеить query-параметры для следующей страницы.
  • Процессы с состояниями. Там, где у сущности есть жизненный цикл (черновик → оплачен → отправлен → возвращён), ссылки на доступные переходы избавляют клиента от дублирования правил.

Где спотыкаются начинающие

  • Путают «REST» с уровнем 3. Услышав, что «настоящий REST — это HATEOAS», начинают считать свой рабочий API «ненастоящим». Уровень 2 — законная и самая распространённая цель, не комплексуйте.
  • Строят HATEOAS, которым никто не пользуется. Обвешивают ответы ссылками, а клиент всё равно хардкодит URL и игнорирует _links. Получается работа сервера впустую — сначала убедитесь, что клиент реально будет ходить по ссылкам.
  • Изобретают свой формат ссылок в каждом endpoint. Если уж делать, берите готовое соглашение (HAL) единообразно, а не «тут _links, там actions, а рядом просто url».
  • Тащат полный уровень 3 в простой CRUD. Для пары справочников гиперссылки на действия — лишняя сложность без отдачи. Начинайте с чистого уровня 2.

Что учить рядом

HATEOAS — это верхушка модели зрелости, поэтому фундамент под ней важнее всего: сначала уверенно освойте REST API на уровне ресурсов и HTTP-методов. Рядом стоят другие стили контракта — gRPC для быстрых внутренних вызовов и GraphQL для гибких срезов данных; полезно понимать, чем они отличаются от REST. Транспорт под всем этим — протокол HTTP, его методы и коды статусов как раз и составляют уровень 2. А как выбор стиля контракта вписывается в проектирование системы целиком — в разделе системного дизайна.

Промежуточный вариант, который берут чаще всего

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

{
  "id": "ord-42",
  "status": "PAID",
  "availableActions": ["refund", "cancel"]
}

Или флаги: canRefund: true, canCancel: false. Разница с полным вариантом одна — клиент всё ещё собирает адрес сам, то есть связанность по адресам остаётся. Зато главная выгода сохраняется: правила живут на сервере, и кнопка на экране появляется по ответу, а не по логике, продублированной в клиенте.

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

Чем расплачивается

Причины, по которым до третьего уровня доходят редко, серьёзнее, чем «мало инструментов».

Ответ становится персональным. Набор ссылок зависит от прав и состояния — значит, два клиента получают разные ответы на один и тот же запрос. Отсюда прямое следствие: общий кэш (прокси, сеть доставки) становится бесполезным, а Cache-Control обязан быть private. Для публичного каталога, который кэшируется на краю сети, это может быть решающим доводом против.

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

Генератор клиента по ссылкам не ходит. Это главная практическая причина. Инструменты, которые строят клиента по описанию API, генерируют вызовы по путям: метод, путь, параметры. Модель «получи ответ, найди связь, перейди по ней» в такую генерацию не укладывается — значит, клиента придётся писать руками, и вся выгода автоматизации теряется. Описать ссылки в OpenAPI можно (раздел links), но генераторы этим почти не пользуются.

Тестировать сложнее. Контрактный тест «ручка возвращает такой-то объект» превращается в «по этому состоянию должны быть такие связи и не должно быть таких» — правил больше, и они завязаны на состояние.

Отсюда честный вывод: полные гиперссылки оправданы там, где клиентов много, они внешние и вы не можете их выкатывать; в остальных случаях берут доступные действия или флаги — дешевле, а главная выгода (правила на сервере) остаётся.

Дополнительно: при первом чтении можно пропустить

Глубже: rel: имя связи важнее адресарасширенное

Вся идея слабой связанности держится на одной вещи, которую легко пропустить: клиент опирается на имя связи (rel), а не на адрес.

{
  "id": "ord-42",
  "status": "PAID",
  "_links": {
    "self":   { "href": "/api/v1/orders/ord-42" },
    "refund": { "href": "/api/v1/orders/ord-42/refunds" },
    "cancel": { "href": "/api/v1/orders/ord-42/cancel" }
  }
}

Клиент не собирает путь сам («возьму идентификатор и допишу /refund») — он ищет в ответе связь с именем refund и идёт по её адресу. Значит, сервер может изменить адрес, перенести действие в другой сервис, добавить в путь параметр — и клиент продолжит работать, потому что имя связи не изменилось.

Отсюда и второе свойство, ради которого всё затевалось: отсутствие связи — это тоже информация. Нет связи refund — возврат сейчас невозможен (не тот статус, нет прав, истёк срок). Клиенту не нужно дублировать бизнес-правила, чтобы решить, показывать ли кнопку: он показывает то, что сервер разрешил.

Имена связей берут из реестра стандартных (self, next, prev, first, last, collection) или объявляют свои — тогда их описывают в документации и не переименовывают: имя связи это часть контракта, ровно как имя поля.

Глубже: как это собирают в кодерасширенное

Для ориентира: в Spring есть отдельный модуль поддержки гиперссылок (Spring HATEOAS). Он даёт обёртки над моделью (EntityModel, CollectionModel), сборку ссылок из методов контроллера (linkTo(methodOn(...)), чтобы адрес не писать строкой) и готовый формат представления (application/hal+json). Второе особенно важно: ссылки собираются из тех же методов, что обслуживают запросы, — значит, переименование пути не рассыпает ссылки молча.

Для ориентира: готового модуля в Go нет, и ссылки собирают руками — в ответе есть поле Links map[string]Link, а адреса строят из тех же шаблонов маршрутов, что зарегистрированы в роутере, чтобы не писать их строкой дважды; формат чаще всего HAL с полем _links.

Для ориентира: в NestJS ссылки добавляет интерсептор или сериализатор — он знает текущий маршрут через Reflector, собирает _links для сущности по её состоянию и правам пользователя и кладёт их в тело; формат обычно HAL, библиотеки вроде nestjs-hateoas экономят шаблонный код, но суть та же.

Для ориентира: в FastAPI адреса строят через request.url_for("route_name", id=...), чтобы ссылка менялась вместе с маршрутом, а модель ответа pydantic несёт поле links; библиотека fastapi-hypermodel делает это декларативно, но и руками выходит десяток строк.

Коротко

  • Модель зрелости описывает три шага: ресурсы вместо одной ручки, правильные методы, гиперссылки в ответе; на практике останавливаются на втором.
  • Смысл гиперссылок — в именах связей: клиент ищет связь refund, а не собирает путь сам, поэтому адрес можно менять. Отсутствие связи означает «сейчас нельзя» — правило остаётся на сервере.
  • Чаще полного варианта берут упрощённый: список доступных действий или флаги canRefund. Правила по-прежнему на сервере, связанность по адресам остаётся.
  • Платят кэшированием (ответ персональный, только private), объёмом ответа и тем, что генератор клиента по ссылкам не ходит — клиента придётся писать руками.
  • Имя связи — часть контракта: его не переименовывают, а свои имена описывают в документации.

Что почитать дальше