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

Когда разработчик впервые видит ваш API, он читает URL. Хорошо выстроенный путь говорит сам за себя: GET /orders/{id}/items — понятно без документации. Плохой путь (/getOrderItemList?orderId=5) заставляет лезть в Swagger каждый раз.

В этой статье разберём, как правильно строить URL: как называть ресурсы, как выбирать HTTP-методы и насколько глубокой может быть вложенность.

Разница между аккуратным и небрежным путём заметна не на одном эндпоинте, а когда их набирается десяток.

один и тот же API — 12 операций, два способа назвать пути существительное + HTTP-метод глагол в пути GET /orders /getOrders POST/orders✓GET/orders/{id}✓PUT/orders/{id}✓DELETE/orders/{id}✓GET/orders/{id}/items /createOrder/getOrderById/updateOrder/deleteOrder/getOrderItemList GET/POST/PUT/PATCH/DELETE /payments6 операций, новое имя одно/getPayments, /createPayment…6 операций — 6 новых имён полоса — сколько имён нельзя угадать, их приходится читать 11 26 312 прочитали в документации первый эндпоинт слева 4 пути выводятся из первого, справа 5 новых имён добавили ресурс: слева правила те же, справа ещё 6 имён 12 операций: помнить 3 имени ресурсов или 12 имён путей

Слева путь — существительное, а действие несёт метод: двенадцать операций укладываются в три имени (orders, items, payments), остальное выводится. Справа у каждой операции своё имя — двенадцать штук, и каждое надо найти в документации.

Четыре цели хорошего URL

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

Предсказуемость — разработчик, знающий один эндпоинт, угадывает остальные. Если есть GET /orders, то логично предположить GET /orders/{id} и POST /orders.

Единообразие — одинаковые правила везде. Не /user-items в одном месте и /orderItems в другом.

Читаемость — URL читается как предложение: GET /orders/{id}/items — «получить позиции заказа».

Стабильность — URL — это публичный контракт. Его изменение ломает чужой код. Менять URL — то же самое, что менять сигнатуру публичного метода библиотеки.

Как записывать пути

Есть несколько простых правил, которых нужно придерживаться всегда.

Партнёр вызвал /api/v1/OrderItems, у нас путь /api/v1/orderItems, и на одном сервере это одно и то же, а на другом 404: регистр в пути зависит от сервера и файловой системы, а подчёркивание в адресной строке сливается с подчёркиванием ссылки. Поэтому пути пишут только строчными буквами и с дефисом между словами, это называется kebab-case:

ПутьГодитсяПочему
/order-itemsдастрочные буквы, слова через дефис
/delivery-addressesдато же правило
/OrderItemsнетрегистр в пути значим: это другой адрес, чем /orderitems
/order_itemsнетподчёркивание исчезает под подчёркиванием ссылки
/deliveryAddressesнеттот же регистр: промахнулся одной буквой — попал не туда

Про регистр стоит сказать отдельно, потому что это не вкусовщина. Имя сайта регистр не различает: SHOP.RU и shop.ru — одно и то же место. А вот путь после имени сайта различает (так устроен сам формат адреса, RFC 3986): /OrderItems и /orderitems — два разных адреса, и сервер вправе отдать на них разное. Строчные буквы просто убирают повод ошибиться.

Подчёркивание мешает по другой причине: браузер и мессенджер рисуют ссылку подчёркнутой, и order_items читается как order items — символ в середине не виден.

Без слеша в конце, без расширений в имени файла:

ПутьГодитсяПочему
/ordersдаимя ресурса и ничего лишнего
/orders/нетэто уже другой адрес — в Spring Boot 3 такой запрос получит 404
/orders.jsonнетформат указывают через заголовок Accept

Раньше слеш в конце был именно вкусовщиной: Spring сам сопоставлял /orders/ с /orders. Начиная со Spring Framework 6 (то есть Spring Boot 3) это поведение выключено — метод setUseTrailingSlashMatch объявлен устаревшим, а значение по умолчанию стало false. Клиент, привыкший дописывать слеш, теперь получит 404 на ровном месте. Поэтому правило и стоит держать: один ресурс — одна запись адреса.

Без глаголов в пути — для действий уже есть HTTP-методы:

ЗапросГодитсяПочему
GET /ordersдаметод GET уже означает «получить»
POST /ordersдаметод POST уже означает «создать»
GET /getOrdersнетглагол в URL повторяет метод
POST /createOrderнетглагол в URL повторяет метод

Коллекции и одиночные ресурсы

Одна из самых частых ошибок — путаница с числом существительного в пути.

Коллекция (список объектов) — множественное число:

/orders               список заказов
/orders/{id}          конкретный заказ
/users                список пользователей
/users/{id}           конкретный пользователь

Singleton (ресурс, который существует в единственном числе для данного контекста) — единственное число:

/users/{id}/profile   профиль пользователя (у каждого один)
/settings             глобальные настройки (одни для всей системы)

Типичная ошибка — смешивать числа:

/order                ✗  используйте /orders
/orders/{id}/item     ✗  используйте /orders/{id}/items

Имена берут из доменного языка

Ресурс должен называться так, как называется понятие в вашем проекте. Если в коде объект называется Order, то путь — /orders, а не /purchases или /transactions. Это упрощает навигацию по коду и документации — везде одно и то же слово.

Примеры соответствия:

  • Order → /orders
  • OrderItem → /orders/{id}/items
  • DeliveryAddress → /delivery-addresses
  • Payment → /payments

HTTP-методы: какой когда использовать

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

МетодНазначениеПовторный вызов безопасен?Типичный статус
GETЧтение данныхДа200
POSTСоздание / командаНет201 + заголовок Location
PUTПолная замена ресурсаДа200 с телом, 204 без тела, 201 если ресурс создан
PATCHЧастичное обновлениеЗависит от тела запроса200 с телом, 204 без тела
DELETEУдалениеДа204

У PUT в колонке статусов три значения, и это не расхлябанность. Обновили существующий заказ и вернули его же в ответе — 200. Обновили, но отвечать нечем (клиент и так знает, что послал) — 204. А если клиент сам выбрал адрес и по этому адресу ничего не было, PUT ресурс создаёт — тогда 201, как у POST. Главное — выбрать что-то одно и держаться этого по всему API.

Колонка «повторный вызов безопасен» про идемпотентность: два одинаковых запроса подряд оставляют систему в том же состоянии, что и один. Ответ потерялся в сети, клиент повторил: PUT со статусом PAID поставит тот же статус ещё раз, и ничего не сломается.

У PATCH это зависит от того, что внутри: «поставь статус PAID» повторять можно сколько угодно, «добавь ещё одну позицию в заказ» нет, каждый повтор добавит новую. Отдельно стоит GET: он не только идемпотентен, но и вовсе не меняет данные, а это разные свойства.

Важное правило: GET не должен менять данные. Это самая опасная ошибка — если операция имеет побочный эффект (списание денег, отмена заказа), она идёт через POST.

// Отмена заказа — это команда с побочным эффектом
@PostMapping("/orders/{id}/cancel")
public OrderResponse cancel(@PathVariable Long id) { ... }

// Так нельзя: GET подразумевает безопасное чтение
@GetMapping("/orders/{id}/cancel")
public OrderResponse cancel(@PathVariable Long id) { ... }

POST также используется для команд, которые не создают новый ресурс, но делают что-то необратимое: POST /orders/{id}/confirm, POST /payments/{id}/refund.

Вложенность: не глубже двух уровней

REST допускает вложенные пути, но злоупотреблять этим не стоит.

До двух уровней — нормально:

/orders/{orderId}                        1 уровень
/orders/{orderId}/items                  2 уровня
/orders/{orderId}/items/{itemId}         2 уровня + идентификатор

Три уровня и глубже — уже сложно:

/users/{userId}/orders/{orderId}/items/{itemId}    ✗  слишком глубоко

Когда хочется сделать три уровня, обычно лучше перейти к плоскому пути с фильтром:

/orders?userId={userId}         ✓  вместо /users/{userId}/orders

Вложенность оправдана, когда дочерний ресурс не существует без родителя. Например, позиция заказа (OrderItem) без заказа (Order) не имеет смысла — значит, /orders/{orderId}/items логично.

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

/payments?orderId={orderId}     ✓  платёж находится и без заказа
/orders/{orderId}/payments      ✗  единственный способ добраться до платежа

Проверка простая: если у ресурса есть жизнь помимо родителя — плоский путь с фильтром; если без родителя он бессмыслен — вложенный.

три уровня /users/{uid}/orders/{oid}/items/{iid} 3 id, 2 связи сверить плоский путь /items/{iid} 1 id, 1 проверка прав

Одна и та же позиция заказа двумя путями: сверху три уровня вложенности, снизу плоский ресурс, где заказ становится фильтром /items?orderId=5.

Идентификатор — всегда в пути, не в теле

Если эндпоинт работает с конкретным объектом, его идентификатор должен быть в URL, а не в теле запроса:

PUT /orders/{id}                ✓
PUT /orders  { "id": 5, ... }   ✗  id в теле запроса

Когда идентификатор в пути один, его часто так и пишут — {id}: имя ресурса стоит рядом, и путаться не с чем. А вот когда их два, каждому нужно своё имя:

/orders/{id}/items/{id}                 ✗  два разных значения под одним именем
/orders/{orderId}/items/{itemId}        ✓  видно, где чей идентификатор

Это не про аккуратность, а про то, что первый вариант просто не работает. Spring Boot 3 такой путь даже не примет: при старте разбор шаблона падает с сообщением «Not allowed to capture 'id' twice in the same pattern». И описать такую операцию в OpenAPI тоже нечем — там параметр опознаётся парой «имя плюс место», а имён два одинаковых.

В коде — те же имена, что в пути:

@GetMapping("/orders/{orderId}/items/{itemId}")
public OrderItemResponse item(@PathVariable Long orderId,
                              @PathVariable Long itemId) { ... }

Нет ресурса и нет метода: 404 против 405

Два отказа, которые путают, хотя различие простое: 404 говорит «такого ресурса нет», 405 — «ресурс есть, но так с ним нельзя».

GET /orders/{id} на несуществующий или удалённый заказ — это 404, а не пустой 200. Пустой ответ с кодом успеха означает «вот заказ, и он пустой», и клиент не сможет отличить «нет заказа» от «заказ без данных»; вся обработка ошибок на стороне клиента на этом ломается. То же для удалённого: если заказ был и удалён, 404 — нормальный ответ (а 410 уместен, когда важно сказать «был, но больше нет навсегда»).

POST /orders/{id} там, где поддерживаются только GET и DELETE, — это 405, и вместе с ним сервер обязан прислать заголовок со списком разрешённых методов (Allow: GET, DELETE). Spring делает это сам, если путь совпал, а метод нет, — и именно поэтому важно, чтобы пути были описаны честно: при опечатке в пути вы получите 404 вместо 405 и будете искать ошибку не там.

Отдельный случай — коллекция. GET /orders?status=PAID, не нашедший ничего, — это 200 с пустым массивом, а не 404: коллекция существует, она просто пуста. 404 для коллекции означало бы, что такого ресурса нет вовсе.

Сколько ресурсов заводить

Вопрос «это поле карточки товара или отдельный ресурс» решается не вкусом, а тремя признаками.

Частота изменения. Остаток товара меняется каждую минуту, описание — раз в месяц. Если они в одном ресурсе, любой клиент, которому нужен остаток, перечитывает описание, а кэширование становится бессмысленным (весь ресурс устаревает по самому быстрому полю). Отсюда /products/{id} и /products/{id}/stock — разные ресурсы с разным временем жизни в кэше.

Права. Цена закупки видна менеджеру, но не покупателю; внутренние заметки — только поддержке. Поля с разными правами в одном ресурсе означают, что ответ надо собирать по-разному для разных ролей, и однажды кто-то забудет фильтр. Отдельный ресурс делает права проверяемыми: доступ к /products/{id}/cost либо есть, либо нет.

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

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

Идентификатор в пути: число или UUID

Выбор влияет не на красоту, а на безопасность и на эксплуатацию.

Последовательное число (/orders/1, /orders/2) удобно читать и легко угадать. Отсюда две беды. Первая — перебор: зная свой заказ 1043, легко попробовать 1042 и 1044, и если проверка прав где-то пропущена, это находка для злоумышленника (а пропущена она будет обязательно — на редком служебном методе). Вторая — утечка информации: по номеру видно, сколько всего заказов в системе и с какой скоростью они появляются; заказать два товара с интервалом в час и сравнить номера — готовая оценка оборота.

UUID (или другой непоследовательный идентификатор) обе проблемы снимает: угадать нельзя, объём не виден. Платят длиной пути и читаемостью (36 символов в адресе, невозможность продиктовать по телефону) и тем, что в базе такой ключ дороже — разбор в статье про UUID в PostgreSQL.

Практическая раскладка, которая встречается чаще всего: внутренний числовой ключ в базе для связей и внешний непоследовательный идентификатор в API. Ещё один вариант — короткий непоследовательный код (ord_7fK2mQ), который и не угадывается, и читается человеком.

И общее правило независимо от выбора: проверка прав никогда не должна зависеть от неугадываемости идентификатора. UUID — это защита от перебора, а не замена проверке «этот заказ принадлежит этому клиенту».

Где REST-именование ломается

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

Поиск со сложными условиями. Фильтр на двадцать полей, вложенные условия, список из тысячи идентификаторов — всё это не влезает в строку запроса (у серверов и прокси предел на длину адреса, обычно 8 килобайт) и не читается. Ответ — POST /orders/search с телом; цена разобрана в статье про параметры запроса.

Отчёты и агрегаты. «Выручка по продавцам за месяц с накопительным итогом» — это не ресурс, а вычисление. Такое оформляют либо как ресурс-отчёт (/reports/revenue?from=…&to=…, и тогда его можно кэшировать), либо отдельной операцией.

Действия, меняющие состояние. Отменить заказ, повторить платёж, отправить письмо — это не создание и не изменение ресурса. Оформляют как подресурс-действие (POST /orders/{id}/cancel), и это осознанное отступление от чистой схемы — разбор в статье про действия и псевдонимы.

Операции над множеством. Пометить сто заказов, удалить пачку, импортировать файл. Для них заводят отдельный ресурс операции (POST /batch-jobs), который возвращает идентификатор и позволяет следить за выполнением, — иначе один запрос висит минутами и его нельзя ни отменить, ни повторить.

Если URL всё-таки пришлось менять

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

Постоянное перенаправление. Старый путь отвечает 301 (или 308, который гарантирует сохранение метода и тела) с заголовком на новый адрес. Клиенты, которые умеют следовать перенаправлениям, продолжат работать; те, что не умеют, получат понятный сигнал, а не ошибку.

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

Срок и наблюдение. У старого пути должна быть метрика обращений и дата отключения. Убирают его не «когда-нибудь», а когда счётчик обращений держится на нуле оговорённый срок, — иначе старый путь живёт годами и никто не решается его тронуть.

Служебные эндпоинты — вне основного API

Большинство API строится на базовом пути /api/v1/.... Но есть служебные эндпоинты, которые нужны инфраструктуре, — они живут отдельно. В Spring Boot их отдаёт Actuator, и пути у него свои, под общим префиксом /actuator:

  • /actuator/health — работает ли приложение.
  • /actuator/health/readiness — готово ли оно принимать трафик (это спрашивает Kubernetes).
  • /actuator/info — версия и метаинформация.
  • /actuator/prometheus — метрики в формате Prometheus; эндпоинт появляется только если добавить зависимость micrometer-registry-prometheus.

Префикс задаётся настройкой management.endpoints.web.base-path — при желании его меняют, например на /internal. Названия вроде /health и /metrics без префикса тоже встречаются, но это чужие соглашения, а не то, что Spring Boot отдаёт сам.

Служебные пути не версионируют и не требуют авторизации пользователя (или защищают через отдельный порт управления — management.server.port).

Коротко

  • URL это публичный контракт, и меняют его как сигнатуру публичного метода: через 301, параллельную поддержку и объявленную дату отключения, а не одним коммитом.
  • Правила записи пути (строчные буквы, дефис, без слеша в конце и без .json) нужны не для красоты, а чтобы клиент не промахнулся: со Spring Boot 3 /orders/ уже отдаёт 404, а не тот же ресурс.
  • Глагол в пути дублирует метод и удваивает словарь: на двенадцать операций хватает трёх имён ресурсов вместо двенадцати имён ручек.
  • Число существительного это обещание клиенту, сколько объектов он получит: множественное у коллекции, единственное у ресурса, который существует в одном экземпляре.
  • Вложенность оправдана, только когда без родителя дочерний ресурс бессмыслен; у ресурса со своей жизнью плоский путь и фильтр, иначе клиент таскает лишние идентификаторы, а сервер сверяет лишние связи.
  • Идентификаторы живут в пути, и у каждого своё имя: {orderId} и {itemId}, а два {id} в одном шаблоне Spring Boot 3 не примет прямо на старте.
  • Выбор между числом и UUID это выбор между читаемостью и защитой от перебора и от оценки оборота по номерам; проверка прав от неугадываемости идентификатора зависеть не должна.
  • Служебные пути Actuator стоят вне /api/v1/, их не версионируют и закрывают отдельным портом или правами.

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