← назад к разделу

CRUD-операций (создать, прочитать, обновить, удалить) хватает не всегда. Иногда нужно сослаться на ресурс без конкретного ID — «мой профиль», «последний деплой». Иногда нужно выразить бизнес-команду — «подтвердить заказ», «отменить подписку». Для обоих случаев в REST есть устойчивые приёмы.

Схема ниже показывает оба приёма разом: когда me действительно что-то выбирает и что меняется в логах и правах, если доменную команду назвать прямо в пути.

что уходит с клиента четыре доменные команды что делает сервер последствие GET /users/me ← обычный пользователь, sub=42 в токене GET /users/42 ← администратор смотрит чужой профиль me → 42 из токена оба запроса идут в один обработчик getUser(id) — различается только сегмент админ мог бы поставить чужой ID — значит сегмент me оправдан клиенту не нужно хранить свой userId отдельно me оправдан: один путь обслуживает и пользователя, и администратора GET /users/me/orders ← тот же sub=42 в токене GET /orders ← ответ ровно тот же me → 42, и другого значения не бывает владельца заказов сервер и так берёт из токена; чужие не отдаёт никому выбирать нечего — me лишний сегмент, хватает GET /orders нет сценария с чужим ID — нет и alias me лишний: сегмент ничего не выбирает, ответ тот же подтвердить отменить отгрузить вернуть деньги PATCH /orders/42 · {"status": "CANCELLED"} все четыре команды — один путь; имя команды остаётся в поле в логе четыре раза одно и то же: PATCH /orders/42 · order updated прав на все четыре операции: одно — orders:write PATCH прячет команду: одна строка в логе и одно право на четыре операции POST /orders/42/confirm · /cancel · /ship · /refund четыре пути, четыре обработчика; имя команды звучит в адресе в логе четыре разные строки — видно, что именно произошло прав четыре: orders:confirm, orders:cancel, orders:ship, orders:refund action называет команду: четыре строки в логе и четыре отдельных права

Сегмент пути несёт смысл: 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
CREATED CONFIRMED SHIPPED REFUNDED POST /confirm POST /ship POST /refund CANCELLED POST /cancel

Жизненный путь заказа: каждый переход делает своя команда со своим правом, поэтому у каждой команды свой адрес.

Как выглядит 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 из неё делать не надо.

Граница проходит по двум признакам сразу, и оба должны совпасть:

  1. Операция переводит объект из одного состояния в другое. У заказа есть жизненный путь: создан → подтверждён → отгружен → закрыт. confirm двигает по этому пути, и из некоторых состояний он запрещён. А описание можно править в любом состоянии сколько угодно раз — оно к жизненному пути отношения не имеет.
  2. На операцию выдают отдельное право. Отменять заказ разрешено не тем же людям, кому разрешено поправить в нём опечатку. Если для операции заводится своя строчка в правах доступа — это команда, ей нужен свой адрес.
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» в журнале.

Что почитать дальше