CRUD-операций (создать, прочитать, обновить, удалить) хватает не всегда. Иногда нужно сослаться на ресурс без конкретного ID — «мой профиль», «последний деплой». Иногда нужно выразить бизнес-команду — «подтвердить заказ», «отменить подписку». Для обоих случаев в REST есть устойчивые приёмы.
Схема ниже показывает оба приёма разом: когда me действительно что-то выбирает и что меняется в логах и правах, если доменную команду назвать прямо в пути.
Сегмент пути несёт смысл: me сервер разворачивает в ID из токена — но только там, где администратор мог бы поставить чужой ID. Четыре доменные команды через PATCH дают одну строку в логе и одно право на всё; через action — четыре пути, четыре строки и четыре отдельных права.
Alias-сегменты: shortcut вместо ID
Обычный REST-путь выглядит так: GET /users/42. Клиент знает ID пользователя и подставляет его. Но что если клиент не знает ID — он хочет «текущего пользователя», «последний деплой», «основную платёжную карту»?
Alias-сегмент — это зарезервированное слово в пути вместо ID. Сервер сам понимает из контекста, какой конкретно объект имеется в виду.
me — псевдоним для текущего пользователя
| Запрос | Кто его шлёт |
|---|---|
GET /users/42 | администратор смотрит на любого пользователя |
GET /users/me | обычный пользователь смотрит на себя |
me — сокращение, которое сервер разворачивает в ID из токена авторизации. Клиенту не нужно хранить свой userId отдельно.
Когда me нужен. Задайте себе вопрос: «Мог бы администратор использовать этот же эндпоинт с чужим ID?» Если да — me полезен. Обычный пользователь ставит me, администратор ставит конкретный ID.
GET /users/me ✓ — администратор мог бы GET /users/42
GET /users/me/settings ✓ — настройки профиля
Когда me не нужен. Если эндпоинт всегда работает только с данными вызывающего и нет никакого «посмотреть чужое», me добавляет шум без пользы:
GET /users/me/orders ✗ — заказы и так берутся из токена, /orders достаточно
GET /orders ✓ — текущий пользователь видит только свои заказы
Отдельный вопрос — писать /users/me или просто /me. Единой конвенции у больших API тут нет: Google использует /users/me, Spotify и Microsoft Graph — корневой /me, а GitHub обходится вовсе без псевдонима, отдавая текущего пользователя по /user.
GET /users/me ✓ — видно, псевдоним какой коллекции
GET /me — тоже встречается, но из пути не видно, чего именно «я»
Мы берём за основу /users/me: сегмент users сразу говорит, что me разворачивается в пользователя, и путь читается одинаково с /users/42. Корневой /me короче, но требует держать в голове, что за ресурс он отдаёт, — и упирается в потолок, как только «текущих» сущностей становится больше одной (/me — это профиль, организация или рабочее пространство?).
Это вопрос соглашения, а не правильности. Выберите один вариант и держите его во всём API.
Временные и порядковые alias
Для выборки «крайнего» объекта из коллекции вместо длинных параметров можно использовать слова-псевдонимы:
| Запрос | Что отдаёт |
|---|---|
GET /deployments/latest | последний деплой — вместо ?sort=createdAt&order=desc&limit=1 |
GET /subscriptions/current | текущая подписка |
GET /invoices/next | следующий счёт |
GET /billing-periods/previous | предыдущий расчётный период |
Это работает только для singleton-выборки — когда контекст однозначно определяет один объект. Если таких объектов может быть несколько, нужна обычная фильтрация с параметрами.
Логические alias
Похожий приём для объектов, выделенных по бизнес-признаку:
| Запрос | Что отдаёт |
|---|---|
GET /payment-methods/default | платёжный метод «по умолчанию» |
GET /addresses/primary | основной адрес |
GET /plans/active | активный тарифный план |
GET /documents/draft | черновик |
Каждый из них — ровно один объект, не список с фильтром.
Слово-псевдоним надо зарезервировать навсегда
Тут прячется ловушка, которую замечают уже в проде. GET /deployments/latest и GET /deployments/{id} живут в одном и том же месте пути. Пока идентификаторы числовые или UUID, всё хорошо: latest ни на что не похоже. А вот если идентификаторы строковые — имена веток, коды тарифов, слаги статей, — рано или поздно кто-нибудь заведёт объект с идентификатором latest.
Дальше произойдёт вот что. Spring, выбирая между двумя подходящими путями, всегда предпочитает тот, где стоит настоящее слово, а не переменная: /deployments/latest точнее, чем /deployments/{id}. Значит, запрос уйдёт в обработчик псевдонима, а объект с идентификатором latest станет недостижим — навсегда и без единой ошибки в логе.
Отсюда правило: слова-псевдонимы (latest, current, next, previous, default, me) заносят в список запрещённых идентификаторов при создании объекта — так же, как admin или null. Запретить один раз дёшево, а вот объяснять потом, почему один тариф из тысячи не открывается, — дорого.
Action-эндпоинты: доменные команды
Есть операции, которые не вписываются в CRUD. «Подтвердить заказ» — это не создание и не обновление в обычном смысле. Это бизнес-команда, у которой есть имя, побочные эффекты и, возможно, входные данные.
Для таких случаев используют action-эндпоинт: ресурс плюс глагол действия.
POST /orders/{id}/confirm
POST /orders/{id}/cancel
POST /orders/{id}/ship
POST /orders/{id}/refund
Жизненный путь заказа: каждый переход делает своя команда со своим правом, поэтому у каждой команды свой адрес.
Как выглядит action-эндпоинт
Путь: ресурс с ID, затем глагол в инфинитиве.
Глагол — именно инфинитив, не существительное и не причастие:
POST /orders/{id}/confirm ✓
POST /orders/{id}/confirmation ✗ — существительное
POST /orders/{id}/confirmed ✗ — причастие
Метод у action — POST. Даже если операция технически идемпотентна (повторный вызов даёт тот же результат), берут POST. Причина простая: action — это команда, а не замена ресурса. PUT означает «положи вот это состояние», POST означает «выполни вот это действие».
Входные данные — в теле запроса:
POST /orders/{id}/ship
Content-Type: application/json
{
"trackingNumber": "TR-123456",
"carrier": "DHL"
}
Если параметров нет, тело может быть пустым.
Чем отвечает action
Про коды ответа у action часто забывают, а они тут как раз нетривиальные — именно потому, что это команда, а не запись поля.
| Что случилось | Код | Почему так |
|---|---|---|
| Команда выполнена, отдаём новое состояние ресурса | 200 | клиент сразу видит результат, лишний GET не нужен |
| Команда выполнена, отвечать нечем | 204 | тело пустое |
| Команда принята, но выполняется долго | 202 + Location на ресурс статуса | возврат денег или отгрузка могут идти минутами |
| Команду нельзя выполнить из текущего состояния | 409 | заказ уже отменён, подтверждать нечего |
| Тело команды не прошло проверку | 422 (или 400) | номер накладной пустой, перевозчик неизвестен |
| Права на эту команду нет | 403 | отменять может не тот, кто может смотреть |
Отдельно — повтор уже выполненной команды. Пришёл второй POST /orders/42/confirm на уже подтверждённый заказ: это ошибка или нет? Оба ответа встречаются, и выбор зависит от того, что для вас команда.
Если confirm — это «доведи заказ до состояния „подтверждён“», повтор ничего не меняет и честно отвечает 200 с текущим состоянием. Клиенту, который потерял ответ из-за обрыва связи, так проще всего. Если же confirm — это событие, которое обязано случиться ровно один раз (и, скажем, шлёт письмо покупателю), повтор возвращает 409: заказ уже не в том состоянии, откуда эту команду выполняют.
Выбирайте по смыслу команды и пишите его в контракте. Хуже всего — не решить и отвечать по-разному в разных сервисах.
Когда action, а когда PATCH
Частая дилемма: изменить статус заказа — это PATCH /orders/{id} с { "status": "CONFIRMED" } или POST /orders/{id}/confirm?
Ориентир «есть доменное имя и побочные эффекты» на слуху, но он расплывчатый: правка описания заказа тоже оставляет запись в журнале изменений, тоже «имеет побочный эффект» — и action из неё делать не надо.
Граница проходит по двум признакам сразу, и оба должны совпасть:
- Операция переводит объект из одного состояния в другое. У заказа есть жизненный путь: создан → подтверждён → отгружен → закрыт.
confirmдвигает по этому пути, и из некоторых состояний он запрещён. А описание можно править в любом состоянии сколько угодно раз — оно к жизненному пути отношения не имеет. - На операцию выдают отдельное право. Отменять заказ разрешено не тем же людям, кому разрешено поправить в нём опечатку. Если для операции заводится своя строчка в правах доступа — это команда, ей нужен свой адрес.
PATCH /orders/{id} { "description": "..." } ✓ — состояние то же, право то же
POST /orders/{id}/confirm ✓ — переход по состояниям, своё право
POST /orders/{id}/cancel ✓ — то же самое
PATCH /orders/{id} { "status": "CANCELLED" } ✗ — переход по состояниям, спрятанный в поле
Последняя строка — та самая ошибка. Статус формально «просто поле», но за его изменением стоит переход, который можно запретить, и право, которое можно не выдать. Спрятав его в PATCH, вы теряете и то и другое.
Action-эндпоинты делают API читаемым: в логах сразу видно POST /orders/42/confirm, а не абстрактное «order updated». Разграничение прав тоже проще — для каждого действия своё разрешение.
Когда команда — это не команда, а желаемое состояние
И есть третий случай, который проще всего пропустить. Иногда «действие» на самом деле означает «пусть станет вот так» — и тогда правильный ответ не POST с командой, а PUT с состоянием.
Корзина на плохой связи. Клиент шлёт POST /cart/items/{productId}/increment — «плюс одна штука». Ответ теряется, клиент повторяет, на сервере становится три штуки вместо двух. Idempotency-Key спасёт от одного лишнего повтора, но клиент всё равно собирает итог из кусочков и на нестабильной связи рано или поздно разойдётся с сервером.
А теперь то же самое иначе:
PUT /cart/items/{productId}
Content-Type: application/json
{ "quantity": 3 }
Это не команда «прибавь», а утверждение «пусть будет три». Повторяйте сколько угодно — три так и останется тремя. Никакого ключа идемпотентности не нужно, потому что идемпотентность тут встроена в смысл запроса. В ответ отдают всю корзину с итогом, и клиенту нечего складывать самому.
Отсюда практическая проверка, прежде чем писать POST /.../increment или /toggle: можно ли сформулировать то же самое как желаемое значение? Если да — берите PUT с этим значением. Если нет («подтвердить», «вернуть деньги», «отправить письмо» — состояния тут нет, есть событие) — тогда это действительно команда, и метод POST.
Action и повторы: почему «всегда POST» опасно
POST выбран потому, что действие меняет состояние, — и ровно поэтому повтор действия опасен. Клиент отправил POST /payments/{id}/refund, не получил ответа (таймаут, обрыв) и не знает, прошёл возврат или нет. Повторит — вернёт деньги дважды; не повторит — может не вернуть вовсе.
Отсюда правило: действие, меняющее деньги или внешнее состояние, обязано принимать ключ идемпотентности.
@PostMapping("/payments/{id}/refund")
public ResponseEntity<RefundView> refund(
@PathVariable UUID id,
@RequestHeader("Idempotency-Key") @NotBlank String idempotencyKey,
@RequestBody @Valid RefundRequest request) { … }
Заголовок здесь обязательный, а не необязательный: если клиент его не прислал, сервер отвечает 428 и объясняет, что нужно. Это честнее, чем принять запрос и надеяться, что повтора не будет.
Механика хранения и правила выдачи ключа разобраны в статье про заголовки; здесь важна связка: как только вы завели действие, надо сразу ответить на вопрос «что будет при повторе». Ответов три, и они разные по цене.
Действие идемпотентно по своей природе. POST /orders/{id}/cancel на уже отменённый заказ — ничего не меняет и отвечает тем же результатом. Ключ не нужен, но нужно явно решить, какой код отдавать на повтор: 200 («уже отменён, вот состояние») обычно лучше, чем 409, потому что клиенту не приходится различать «я отменил» и «было отменено до меня».
Действие не идемпотентно, но обратимо. Отправка письма: второе письмо — неприятно, но не катастрофа. Здесь ключ желателен, а на повтор без ключа отвечают как на новый запрос.
Действие не идемпотентно и необратимо. Возврат денег, списание, выдача товара. Ключ обязателен, и без него запрос не принимают.
Права на действие: как это выглядит в коде
Довод «orders:confirm отдельно от orders:write» стоит показать целиком, иначе он остаётся теорией.
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderActionsController {
private final OrderActions actions;
@PostMapping("/{id}/confirm")
@PreAuthorize("hasAuthority('SCOPE_orders:confirm')")
public OrderView confirm(@PathVariable UUID id) {
return OrderView.of(actions.confirm(id));
}
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAuthority('SCOPE_orders:cancel') or @orderOwnership.isOwner(#id, authentication)")
public OrderView cancel(@PathVariable UUID id, @RequestBody @Valid CancelRequest request) {
return OrderView.of(actions.cancel(id, request.reason()));
}
@PostMapping("/{id}/refund")
@PreAuthorize("hasAuthority('SCOPE_orders:refund')")
public RefundView refund(@PathVariable UUID id,
@RequestHeader("Idempotency-Key") @NotBlank String key,
@RequestBody @Valid RefundRequest request) {
return RefundView.of(actions.refund(id, request, key));
}
}
Что здесь важно. Разные права на разные действия — это и есть главная практическая выгода отдельных путей: подтверждение доступно оператору, возврат — только старшему оператору, отмена — ещё и самому покупателю (проверка владения через отдельный компонент). С единой ручкой PATCH /orders/{id} такое разделение пришлось бы делать внутри метода, по содержимому запроса, — и его невозможно ни увидеть в описании API, ни проверить тестом на уровне контракта.
Второе: проверка прав (может ли эта роль так делать) отделена от проверки состояния (можно ли отменить уже доставленный заказ). Первая живёт в аннотации, вторая — в домене, и отвечает на неё 409, а не 403.
Сколько действий допустимо
Действия — законное отступление от схемы «существительное плюс метод», и потому их легко развести слишком много. Признаки, что пора остановиться.
Больше пяти-шести действий у одного ресурса обычно означает, что ресурс не тот: у заказа не бывает пятнадцати разных операций, а вот у «процесса обработки заказа» — бывают. Тогда состояние выносят в отдельный ресурс.
Действия, которые меняют только статус, складываются в один ресурс перехода. Вместо confirm, start-shipping, deliver, close — ресурс статуса со своей историей:
GET /orders/{id}/status → { "value": "PAID", "changedAt": "…", "allowedNext": ["SHIPPED", "CANCELLED"] }
POST /orders/{id}/status-changes → { "to": "SHIPPED", "reason": "…" }
GET /orders/{id}/status-changes → история переходов
Выигрыш двойной: клиент видит, какие переходы доступны сейчас (и не угадывает, какое действие вызвать), а история переходов становится данными, а не журналом. Это же снимает вопрос «что вернуть, если переход невозможен»: 409 с текущим состоянием и списком допустимых переходов.
Действия, которые на самом деле создают сущность. POST /orders/{id}/refund можно оформить и как создание: POST /orders/{id}/refunds, и тогда возврат становится ресурсом — у него появляется идентификатор, состояние, история, возможность получить список. Признак, по которому выбирают: если результат действия имеет самостоятельную жизнь (его можно посмотреть, отменить, повторить), это ресурс, а не действие.
Массовые действия и частичный успех
Самый спорный случай: POST /orders/confirm со списком идентификаторов. Спорный потому, что результат почти никогда не бывает целиком успешным.
Три честных варианта, и выбирать надо осознанно.
Всё или ничего. Операция в одной транзакции: не прошло одно — не прошло ничего, ответ 409 с указанием виновника. Подходит, когда список мал и связан по смыслу (подтвердить три позиции одного заказа). Не подходит для сотен элементов: одна плохая строка блокирует остальные.
Частичный успех с разбором по элементам. Ответ 207 (или 200 с телом, где перечислены исходы) и список результатов по каждому элементу:
{
"results": [
{ "id": "ord-1", "status": "CONFIRMED" },
{ "id": "ord-2", "error": { "code": "ORDER_ALREADY_PAID", "status": 409 } },
{ "id": "ord-3", "status": "CONFIRMED" }
]
}
Клиент разбирает список и решает, что делать с неудачными. Это самый практичный вариант для пачек, и у него два обязательных свойства: порядок результатов соответствует порядку входа (или в каждом результате есть идентификатор), и код ответа не 200 в чистом виде, чтобы клиент не решил, что всё прошло.
Асинхронная задача. Для больших пачек — 202 с идентификатором задачи и адресом для проверки состояния; результат по элементам доступен по окончании. Разбор — в статье про пакетные и асинхронные операции.
Чего делать нельзя: отвечать 200 без разбора по элементам («обработано») и оставлять клиента в неведении, что половина не прошла.
Можно ли писать в псевдоним
Псевдонимы в статье даны на чтение (/orders/latest, /me), и вопрос «а изменять через них можно» законный.
GET и PUT по псевдонимам — разные по смыслу. GET /me («мой профиль») очевиден. А вот PUT /payment-methods/default читается двусмысленно: это «изменить способ оплаты, который сейчас по умолчанию» или «назначить способ по умолчанию»? Первое опасно: клиент думает, что назначает, а меняет данные другого объекта.
Поэтому правило: псевдоним для чтения — да, для изменения — только если он однозначен. Назначение «того самого» объекта оформляют явно, как действие или как изменение свойства владельца:
PUT /customers/me/default-payment-method { "paymentMethodId": "pm-42" } ← однозначно
POST /payment-methods/{id}/make-default ← тоже однозначно
PUT /payment-methods/default { … } ← двусмысленно, не надо
Первый вариант лучше второго, потому что «способ оплаты по умолчанию» — это свойство покупателя, а не самого способа оплаты, и в схеме оно тоже хранится у покупателя. Третий — тот случай, когда псевдоним экономит сегмент пути и создаёт вопрос у каждого нового человека в команде.
Частые ошибки
me там, где эндпоинт работает только с данными текущего пользователя. Если нет admin-сценария с чужим ID, me не добавляет смысла — он просто лишний сегмент.
Псевдоним не зарезервирован. Завели /deployments/latest, а слово latest не запретили как идентификатор — и объект с таким идентификатором стал недостижим.
Существительное или причастие в action. /orders/{id}/confirmation — это выглядит как ресурс, а не команда. Правильно: /orders/{id}/confirm.
PUT там, где действительно команда. PUT /orders/{id}/confirm — противоречие: PUT кладёт состояние по адресу, а /confirm — не адрес состояния. Но если операция формулируется как желаемое значение ({ "quantity": 3 }), PUT на сам ресурс — правильный выбор, и action не нужен вовсе.
Длинное имя action. cancelTheOrderImmediately — лишние слова. Достаточно cancel.
Коротко
- Alias-сегмент — зарезервированное слово вместо ID; сервер разворачивает его из контекста.
me— псевдоним для текущего пользователя; нужен только когда есть admin-сценарий с чужим ID. Мы пишем/users/me; корневой/meтоже встречается — это соглашение, а не правило.- Временные alias (
latest,current,next,previous) — для singleton-выборки крайнего объекта. - Логические alias (
default,primary,active,draft) — для объекта, выделенного по бизнес-признаку. - Любое слово-псевдоним заносят в запрещённые идентификаторы: литерал в пути всегда выигрывает у переменной, и объект с таким идентификатором станет недостижим.
- Action-эндпоинт — для доменных команд: ресурс + глагол-инфинитив, метод
POST. Ответы:200/204при успехе,202для долгой команды,409если из текущего состояния нельзя,422на негодное тело. - Выбор между action и
PATCH: переход по состояниям плюс отдельное право → action; правка поля, которую можно делать в любом состоянии →PATCH. - Если «действие» формулируется как желаемое значение (
{ "quantity": 3 }), это вообще не action — этоPUTна ресурс, и он идемпотентен сам по себе. - Главный довод за отдельный адрес действия: у команды своё право доступа и своя строка в логе, а
PATCHсоstatusдаёт одно право на все переходы и одинаковое «order updated» в журнале.
Что почитать дальше
- URL и ресурсы в REST API — структура пути, именование ресурсов, семантика GET, POST, PUT, PATCH, DELETE.
- Ошибки в REST API: какие коды отдавать, когда команда не проходит по состоянию или по телу запроса.
- Версионирование REST API — как эволюционировать эндпоинты без поломки клиентов.