Коллекции, элементы и вложенные ресурсы
Урок о двух базовых видах ресурсов — коллекции и элементе — и о том, как и когда их вкладывать друг в друга.
Коллекция — это ресурс-список однотипных элементов (
/orders), а элемент — один конкретный объект из неё, адресуемый идентификатором (/orders/42).
Почти любой REST-ресурс существует в одной из двух ролей. Либо это множество — «все заказы», «все статьи», — либо конкретный экземпляр — «заказ №42». Эта пара — коллекция и элемент — фундамент всей навигации по API. Понимание того, чем они отличаются по поведению, и когда один ресурс уместно вложить внутрь другого, отделяет аккуратное API от лабиринта.
Коллекция против элемента
Один и тот же базовый путь работает по-разному в зависимости от того, есть после него идентификатор или нет. У коллекции и элемента — разные допустимые методы и разный смысл.
| Запрос | Цель | Смысл |
GET /orders | коллекция | получить список заказов (с фильтрами/пагинацией) |
POST /orders | коллекция | создать новый заказ в коллекции |
GET /orders/42 | элемент | получить один заказ |
PATCH /orders/42 | элемент | частично изменить этот заказ |
DELETE /orders/42 | элемент | удалить этот заказ |
Обратите внимание на асимметрию: POST применяют к коллекции (она «рожает» новый элемент и возвращает его адрес), а PATCH/DELETE — к элементу. DELETE /orders (всю коллекцию разом) почти всегда либо запрещают, либо тщательно ограничивают — слишком опасно.
Ответ коллекции — это конверт, а не голый массив
Коллекция почти никогда не отдаёт все элементы сразу: их могут быть миллионы. Поэтому ответ оборачивают в «конверт» с метаданными пагинации.
{
"data": [
{ "id": 42, "total": 1990, "status": "paid" },
{ "id": 43, "total": 540, "status": "new" }
],
"page": 1,
"per_page": 20,
"total": 134
}
Фильтры и сортировку коллекции выражают query-параметрами, а не новыми путями: GET /orders?status=paid&sort=-created_at&page=2. Путь определяет, что за ресурс, а query-строка — какой срез этого ресурса мы хотим.
Вложенные ресурсы
Когда один ресурс принадлежит другому, отношение выражают вложенностью пути. Заказы конкретного пользователя — это /users/1/orders: «коллекция заказов, принадлежащих пользователю 1».
GET /users/1/orders коллекция заказов пользователя 1 GET /users/1/orders/42 заказ 42 этого пользователя POST /users/1/orders создать заказ для пользователя 1
Вложенность хороша тем, что выражает контекст и владение прямо в адресе. Она же задаёт область фильтрации: /users/1/orders автоматически означает «только заказы пользователя 1». Но у вложенности есть цена — длина и жёсткость, — поэтому вкладывать нужно с мерой.
Правило: глубина не больше двух уровней
Разумный предел — две сущности с идентификаторами в пути: /коллекция/{id}/подколлекция. Дальше адрес становится хрупким и нечитаемым.
| URI | Оценка |
/users/1/orders | хорошо — одно владение |
/users/1/orders/42 | хорошо — элемент вложенной коллекции |
/users/1/orders/42/items/7/discounts/3 | плохо — слишком глубоко, хрупко |
Когда вложенность тянет на третий уровень, «выпрямите» путь: адресуйте глубокий ресурс по его собственному идентификатору от корня. Вместо /users/1/orders/42 для самого заказа достаточно /orders/42 — у заказа есть глобально уникальный id, и пользователь из пути избыточен.
Глубоко (хрупко): GET /users/1/orders/42/items/7
Выпрямлено: GET /order-items/7
Контекст фильтра: GET /orders/42/items (список — ок)
GET /order-items/7 (элемент — от корня)
Когда вкладывать, а когда нет
- Вкладывайте, когда дочерний ресурс не существует без родителя и вы перечисляете список в его контексте:
GET /posts/15/comments. - Не вкладывайте, когда у ресурса есть собственный устойчивый идентификатор и к нему обращаются напрямую: один комментарий —
GET /comments/88, а не/posts/15/comments/88. - Не вкладывайте «многие-ко-многим» глубоко: связь тегов и статей лучше выразить через
/articles/15/tagsи/tags/9/articlesпо отдельности, чем строить/tags/9/articles/15/....
Идентификаторы: id или slug
Чем адресовать элемент — числовым id или человекочитаемым slug? У обоих своя ниша.
| Критерий | Числовой id | Slug |
| пример | /articles/137 | /articles/rest-design |
| стабильность | не меняется никогда | может меняться при переименовании |
| читаемость/SEO | нулевая | высокая |
| риск перебора | последовательные id легко перечислить | сложнее угадать |
Практичный компромисс — отдавать оба и резолвить любой: внутренний стабильный id для надёжности связей и публичный slug для адресов. При смене slug возвращайте 301 со старого на новый, чтобы ссылки не ломались. Для не-перечислимых публичных идентификаторов берут UUID или короткий случайный код (/files/9aF3kZ) вместо инкрементного id.
Как работает под капотом
Для вложенного маршрута фреймворк извлекает несколько параметров из пути и обычно проверяет согласованность «родитель—ребёнок» на уровне запроса к данным.
Маршрут: GET /users/{userId}/orders/{orderId}
Запрос: GET /users/1/orders/42
Парсинг: userId = 1, orderId = 42
Выборка: SELECT * FROM orders WHERE id = 42 AND user_id = 1
Ключевая деталь — условие AND user_id = 1. Без него GET /users/1/orders/42 вернул бы заказ 42, даже если он принадлежит другому пользователю — классическая уязвимость IDOR (небезопасная прямая ссылка на объект). Вложенный путь обязан проверяться целиком, а не только по последнему идентификатору. Именно поэтому «выпрямление» до /orders/42 требует отдельной проверки прав: раз пользователь ушёл из пути, авторизацию надо навесить в обработчике.
Пагинация коллекции под капотом — это LIMIT/OFFSET (или курсор по индексу): ?page=2&per_page=20 превращается в LIMIT 20 OFFSET 20. Поэтому отдавать коллекцию без пагинации опасно: один запрос может вытащить миллион строк и положить базу.
Частые ошибки
- Глубокая вложенность.
/users/1/orders/42/items/7/options/3— нечитаемо и хрупко. Держите максимум два уровня, глубокие ресурсы выпрямляйте. - Голый массив в ответе коллекции.
[ {...}, {...} ]без метаданных не даёт расширить ответ пагинацией без слома клиентов. Используйте конверт. - Фильтры как пути.
/orders/paidвместо/orders?status=paid— срез коллекции это query, а не новый ресурс. - Игнор владения во вложенном пути. Выборка только по
orderIdбез проверкиuserIdоткрывает IDOR. - Инкрементные id в публичных адресах чувствительных данных. Последовательные id легко перебрать; для приватного используйте UUID/код.
Итоги
- Коллекция (
/orders) и элемент (/orders/42) — две роли с разными методами:POSTна коллекцию,PATCH/DELETEна элемент. - Срезы коллекции (фильтр, сортировка, страница) — это query-параметры, а не новые пути.
- Вложенность выражает владение, но глубже двух уровней путь выпрямляют до корня.
- Вкладывайте для списков в контексте, обращайтесь к элементу по его собственному идентификатору.
- Стабильный внутренний
id+ человекочитаемыйslug; для приватного — UUID, и всегда проверяйте владение.