Проектирование URI: существительные, не глаголы
Урок о том, почему хороший URI описывает вещь, а не действие, и как сделать пути предсказуемыми.
URI ресурса — это адрес сущности в системе, который называет что мы трогаем, а не что с этим делаем; глагол берёт на себя HTTP-метод.
Когда новичок впервые проектирует API, он почти всегда переносит в URL мышление обычной функции: есть процедура getUsers() — значит, и адрес будет /getUsers. Сначала появляется /getUsers, потом /createUser, затем /updateUserEmail, и через месяц у вас сорок ручек, каждая со своим словарём. Никто, кроме автора, не угадает имя следующей. Это и есть главная боль плохих URI: они непредсказуемы. REST предлагает другой контракт — и в нём адрес почти всегда можно угадать.
Глагол уже есть — это HTTP-метод
HTTP с рождения двуязычен: в каждом запросе есть метод (глагол) и путь (существительное). Метод говорит, что сделать; путь говорит, с чем. Поэтому действие в URL — это дублирование. Сравните две версии одного и того же набора операций над пользователями.
| Операция | Плохо (глагол в URL) | Хорошо (метод + существительное) |
| список пользователей | GET /getUsers | GET /users |
| один пользователь | GET /getUserById?id=42 | GET /users/42 |
| создать | POST /createUser | POST /users |
| изменить | POST /updateUser | PATCH /users/42 |
| удалить | POST /deleteUser?id=42 | DELETE /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в пути. - Предсказуемость — главная ценность: по одному адресу клиент угадывает остальные.