Проектирование URI: существительные, не глаголы

Урок о том, почему хороший URI описывает вещь, а не действие, и как сделать пути предсказуемыми.

URI ресурса — это адрес сущности в системе, который называет что мы трогаем, а не что с этим делаем; глагол берёт на себя HTTP-метод.

Когда новичок впервые проектирует API, он почти всегда переносит в URL мышление обычной функции: есть процедура getUsers() — значит, и адрес будет /getUsers. Сначала появляется /getUsers, потом /createUser, затем /updateUserEmail, и через месяц у вас сорок ручек, каждая со своим словарём. Никто, кроме автора, не угадает имя следующей. Это и есть главная боль плохих URI: они непредсказуемы. REST предлагает другой контракт — и в нём адрес почти всегда можно угадать.

Глагол уже есть — это HTTP-метод

HTTP с рождения двуязычен: в каждом запросе есть метод (глагол) и путь (существительное). Метод говорит, что сделать; путь говорит, с чем. Поэтому действие в URL — это дублирование. Сравните две версии одного и того же набора операций над пользователями.

ОперацияПлохо (глагол в URL)Хорошо (метод + существительное)
список пользователейGET /getUsersGET /users
один пользовательGET /getUserById?id=42GET /users/42
создатьPOST /createUserPOST /users
изменитьPOST /updateUserPATCH /users/42
удалитьPOST /deleteUser?id=42DELETE /users/42

В правой колонке существительное одно — users — а разнообразие операций берёт на себя метод. Клиент, увидев GET /users/42, без документации догадается, что DELETE /users/42 удалит того же пользователя. Это и есть предсказуемость: API превращается из набора случайных имён в единую грамматику.

Существительные во множественном числе

Базовое имя ресурса-коллекции пишут во множественном числе: /users, /orders, /articles. Это читается естественно: /users — «множество пользователей», /users/42 — «пользователь номер 42 из этого множества». Смешивать единственное и множественное (/user и /orders в одном API) — частый источник путаницы: клиент не помнит, где как.

GET  /products          # коллекция товаров
GET  /products/77       # один товар
GET  /categories        # коллекция категорий
GET  /categories/5      # одна категория

Исключение — синглтон-ресурсы, которые в системе существуют в единственном экземпляре в данном контексте: например, корзина текущего пользователя или его настройки. Тогда допустимо единственное число — /cart, /profile, /settings, — потому что объект и правда один.

Иерархия путей и читаемость

Сегменты пути читаются слева направо как уточнение: от общего к частному. Хороший URI можно прочитать вслух и понять без схемы.

GET /blogs/dev/posts/hello-rest/comments
     |     |    |        |          |
   блоги  dev  посты  пост-slug   комментарии

Этот адрес сам себя объясняет: «комментарии к посту hello-rest в блоге dev». Каждый сегмент — существительное или идентификатор. Адрес вроде /blog?action=getComments&post=hello-rest требует от читателя расшифровки и не масштабируется.

Единый стиль: kebab-case

Если имя ресурса состоит из нескольких слов, разделяйте их дефисом — это kebab-case: /order-items, /blog-posts, /shipping-addresses. Дефис выбран не случайно: поисковые системы трактуют его как разделитель слов, а подчёркивание _ — нет; к тому же camelCase в путях ломается о регистр (см. ниже).

СтильПримерВ путях REST
kebab-case/order-itemsрекомендуется
snake_case/order_itemsдопустимо, но хуже для SEO
camelCase/orderItemsизбегать (регистр)

Регистр: только нижний

Пути держите в нижнем регистре. По RFC 3986 хост в URL регистронезависим, а вот путь — регистрозависим: /Users и /users формально разные ресурсы. Если в API встречаются оба написания, вы гарантированно получите баги «у меня работает, у тебя 404». Один регистр для всех путей убирает целый класс ошибок.

Как работает под капотом

URI не содержит «магии» — это просто строка, которую веб-сервер сопоставляет с обработчиком по таблице маршрутов. Когда фреймворк регистрирует маршрут GET /users/{id}, он компилирует шаблон в регулярное выражение примерно такого вида:

^/users/(?<id>[^/]+)$   →  handler: UsersController.show

Приходит запрос GET /users/42 — роутер перебирает шаблоны сверху вниз, находит совпадение, извлекает id = 42 и вызывает обработчик. Метод (GET/POST/DELETE) — отдельное измерение в той же таблице, поэтому GET /users/42 и DELETE /users/42 ведут к разным функциям при одном пути. Именно эта двумерность (путь × метод) и позволяет не плодить глаголы в URL.

Регистр объясняется тем же механизмом: шаблон /users по умолчанию не совпадёт со строкой /Users, потому что регэксп чувствителен к регистру. А .json на конце ломает совпадение целиком: /users/42.json не подойдёт под шаблон /users/{id}, если только вы специально не добавили обработку расширения.

Без расширений .json в пути

Формат ответа — это про представление ресурса, а не про сам ресурс. Один и тот же пользователь может быть отдан как JSON, XML или CSV, но это всё ещё /users/42. Поэтому формат не зашивают в путь через .json, а согласуют заголовком Accept (контент-негоциация).

# Плохо: формат зашит в URI
curl https://api.example.com/users/42.json

# Хорошо: ресурс один, формат — в заголовке
curl -H "Accept: application/json" https://api.example.com/users/42

Если контент-негоциация для вашей аудитории слишком тонкая, компромисс — query-параметр ?format=json: он тоже не делает формат частью идентичности ресурса. А вот .json в пути создаёт два разных URI для одной сущности и засоряет кэши и логи.

Частые ошибки

  • Глаголы в пути. /getUser, /createOrder — действие уже выражено методом. Оставляйте в URL только существительные.
  • Смешение чисел. /user рядом с /orders — клиент не запомнит, где как. Договоритесь о множественном и держитесь его.
  • Разнобой в стиле. /orderItems в одном месте и /order-items в другом. Один стиль (kebab-case) на весь API.
  • Прописные буквы. /Users и /users технически разные. Только нижний регистр.
  • Расширение формата. /users/42.json — формат это заголовок Accept, а не часть адреса.
  • Параметры вместо пути для идентификатора. /users?id=42 вместо /users/42: идентичность ресурса принадлежит пути, фильтры — query-строке.

Итоги

  • URI называет ресурс (существительное), а действие выражает HTTP-метод.
  • Коллекции — во множественном числе: /users, /orders; синглтоны — в единственном: /cart.
  • Путь читается слева направо от общего к частному и должен быть понятен вслух.
  • Единый стиль: kebab-case, только нижний регистр, без .json в пути.
  • Предсказуемость — главная ценность: по одному адресу клиент угадывает остальные.
Проверьте себя
1. Какой из вариантов лучше всего соответствует принципам проектирования URI в REST?
APOST /createUser
BGET /getUserById?id=42
CPOST /users
DGET /user/getAll
2. Почему формат ответа не зашивают в путь через .json (например, /users/42.json)?
AЭто запрещено стандартом HTTP
BФормат — это представление ресурса; его согласуют заголовком Accept, а .json создаёт второй URI для той же сущности
CСервер не сможет распарсить точку в пути
DJSON нельзя отдавать по GET
3. Почему пути в API держат в нижнем регистре?
AТак быстрее работает сервер
BЗаглавные буквы запрещены в URL
CПуть в URL регистрозависим, поэтому /Users и /users — формально разные ресурсы, и разнобой даёт баги 404
DНижний регистр короче