Коллекции, элементы и вложенные ресурсы

Урок о двух базовых видах ресурсов — коллекции и элементе — и о том, как и когда их вкладывать друг в друга.

Коллекция — это ресурс-список однотипных элементов (/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? У обоих своя ниша.

КритерийЧисловой idSlug
пример/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, и всегда проверяйте владение.
Проверьте себя
1. Где правильнее всего создать новый заказ в REST API?
APOST /orders/new
BPOST /orders
CPUT /orders/create
DPOST /createOrder
2. Какая глубина вложенности считается разумным пределом для пути?
AНе больше одного уровня — вложенность вообще избегают
BОколо двух сущностей с идентификаторами, глубже — выпрямляют до корня
CСколько угодно, лишь бы отражало структуру данных
DРовно четыре уровня
3. Чем грозит выборка вложенного ресурса только по последнему идентификатору, например WHERE id = 42 без AND user_id = 1?
AНичем, это нормальная оптимизация
BЗамедлением запроса
CУязвимостью IDOR: можно получить чужой объект, подставив свой userId в путь
DОшибкой 500 на сервере
4. Как лучше всего выразить срез коллекции — только оплаченные заказы, отсортированные по дате?
AGET /orders/paid/by-date
BGET /paid-orders
CGET /orders?status=paid&sort=-created_at
DGET /getPaidOrders