1. Командные URI мешают жить
Командные URI быстро превращают API в каталог команд. Когда вы впервые делаете API, в голове обычно не ресурсы, а задачи из таск-трекера (простите за каламбур). В требованиях написано: “Нужно завершить задачу”, “Нужно прикрепить файл”, “Нужно поменять статус”. И рука сама тянется сделать что-нибудь вроде POST /api/v1/completeTask. Кажется логично: есть действие — есть адрес для действия.
Проблема в том, что такой путь действительно превращает API в каталог команд. Клиенту приходится запоминать десятки “глагольных” адресов: /api/v1/completeTask, /api/v1/changeStatus, /api/v1/uploadFileToTask, /api/v1/renameTask, /api/v1/assignUser… И каждый новый сценарий почти автоматически рождает новый эндпоинт. В итоге API начинает напоминать не карту города, а список заклинаний из фэнтези-игры: “произнеси /archiveTask — и всё случится”.
Ещё хуже, что командные URI ломают идею предсказуемости. В ресурсном API вы можете почти угадать, как будет выглядеть адрес: если есть задача, значит есть /tasks/{taskId}. Если у задачи есть комментарии, значит есть /tasks/{taskId}/comments. А вот командный стиль угадать не даёт: вы пишете completeTask, ваш коллега пишет finish, третий — markDone, четвёртый — close. Клиенту остаётся только страдать и читать документацию как роман на 800 страниц.
Самое неприятное — командный стиль обычно приводит к смешению смысла HTTP-методов. Если вы сделали GET /api/v1/completeTask?taskId=..., вы уже нарушили “безопасность” GET (safe method): один невинный автоповтор запроса, один кеш на прокси — и внезапно “завершение задач” происходит само по себе. Да, звучит как баг из параллельной вселенной, но такие баги существуют ровно потому, что URI начали выражать действие вместо адреса ресурса.
2. URI — существительное, метод — глагол
Если вы запомните из этой лекции одну фразу, пусть это будет она: URI — это существительное, метод — это глагол. URI должен отвечать на вопрос “с чем мы работаем?” (ресурс), а HTTP-метод — на вопрос “что мы делаем с этим ресурсом?”.
Представьте обычный адрес дома. Адрес не меняется от того, что вы хотите сделать: посмотреть дом, купить дом, сделать ремонт или снести его (не делайте так без разрешения, это плохая практика). Адрес — это “где”, действие — это “что”. В REST API то же самое: /api/v1/tasks/{taskId} — это “где находится задача”, а GET/PUT/PATCH/DELETE — это то, “что вы хотите с ней сделать”.
Отсюда вытекает очень практичный критерий: если в URI появился глагол, спросите себя, не пытаетесь ли вы вложить в путь то, что уже должен выражать HTTP-метод. Звучит как рекурсия ради рекурсии, но на практике это работает удивительно хорошо.
Например, у нас есть две записи:
# Плохо: URI описывает действие (командный стиль)
POST /api/v1/completeTask
# Хорошо: URI описывает ресурс, а действие выражено HTTP-методом
PATCH /api/v1/tasks/{taskId}
Во втором варианте URI стабилен: он про ресурс task. А действие (частичное изменение) выражено методом PATCH. Именно поэтому во многих ресурсных API “завершить задачу” — это не отдельная команда, а частичное обновление состояния задачи.
И ещё один важный кусочек: тело запроса (request body) — это место, где вы описываете, какое именно изменение вы хотите. Не путь должен рассказывать “заверши задачу”, а тело должно говорить “статус теперь DONE” (или другое нужное состояние). Путь остаётся адресом.
3. Действие = изменение состояния
Большинство бизнес-действий в ресурсном API — это просто изменение состояния. Когда бизнес говорит “заверши задачу”, он описывает действие человеческими словами. А ресурсный API переводит это на язык состояния: у задачи есть поле status, значит “завершить” — это “перевести status в значение DONE”. Мы сейчас не обсуждаем детали правил переходов и конфликтов, мы только учимся видеть правильную форму URI.
С точки зрения URI у нас всё просто: если мы меняем задачу, мы работаем с /api/v1/tasks/{taskId}. Не с /completeTask, не с /tasks/{taskId}/complete, не с /doComplete, а именно с адресом задачи. Дальше уже подбираем метод: чаще всего для “изменить один-два атрибута” используют PATCH, а для “заменить всё изменяемое состояние” — PUT. Здесь важна сама мысль: URI не должен распухать от бизнес-действий.
Вот как это может выглядеть в виде простого .http-сценария (пока как концепт, без обсуждения статусов ответа и схем DTO — это будет позже):
# Частичное обновление состояния конкретной задачи
PATCH /api/v1/tasks/2b1f8d3a-7c0e-4e21-9a7a-0d3d0c9a7f11
Content-Type: application/json
{
# Команда спрятана в данных: меняем статус ресурса task
"status": "DONE"
}
Здесь “команда” спрятана не в URI, а в данных: мы говорим, что у ресурса task меняется поле status. Если потом появится действие “заблокировать задачу”, URI не меняется — меняется значение status. Это резко снижает вероятность того, что API разрастётся в тысячу ручек.
Аналогично можно думать и про другие бизнес-операции, которые на самом деле являются изменением состояния: назначение ответственного (assigneeName), добавление/удаление тега (в нашем проекте это часть задачи), изменение dueDate. Если вы каждый раз делаете отдельную команду, вы строите RPC. Если вы работаете с состоянием ресурса, вы остаётесь в ресурсной модели.
Небольшой технический трюк, который помогает мозгу: попробуйте прочитать URI вслух как существительное. /api/v1/tasks/{taskId} — “задача с таким-то id” звучит нормально. А /api/v1/completeTask — это уже “выполни задачу”, то есть чистая команда. Когда вы слышите команду, вы почти всегда чувствуете, что это не адрес.
4. Подресурсы для комментариев и файлов
Если после операции появляется новый объект, это обычно не команда, а создание ресурса. Самые понятные примеры в нашем домене — комментарии и вложения. Когда вы “добавляете комментарий”, вы не просто меняете поле у задачи — вы создаёте новый комментарий, у которого есть собственный commentId, автор, текст и время создания.
Ресурсный стиль здесь буквально просится: комментарии — это подресурс задачи. Значит, мы работаем с коллекцией комментариев конкретной задачи: /api/v1/tasks/{taskId}/comments. И действие “добавить комментарий” становится обычным созданием элемента коллекции через POST.
С вложениями история похожая: “прикрепить файл” на самом деле означает “создать новый ресурс вложения, связанный с этой задачей”. Поэтому вместо POST /api/v1/uploadFileToTask мы предпочитаем POST /api/v1/tasks/{taskId}/attachments. URI остаётся существительным, а действие выражается методом и форматом запроса (multipart будет позже, сейчас это просто идея).
Чтобы не путаться, полезно держать в голове маленькое правило: если после операции появляется новый идентификатор (например, commentId или attachmentId), то почти наверняка вы создаёте новый ресурс, а не “выполняете команду”.
Практически это означает две формы адреса:
- POST /api/v1/tasks/{taskId}/comments
- POST /api/v1/tasks/{taskId}/attachments
Этого уже достаточно, чтобы не скатиться к addCommentToTask и uploadFileToTask.
5. Суффикс действия как редкое исключение
Суффикс действия допустим редко — и только когда он действительно уточняет представление ресурса. Иногда вы всё делаете правильно, но сталкиваетесь с неприятной реальностью: один и тот же объект хочется получить в разных формах. Вложения — классический случай. У вложения есть метаданные (имя файла, размер, тип содержимого, описание, дата загрузки) — их удобно отдавать JSON-ом. Но есть и бинарное содержимое файла: в JSON это отдавать странно, и клиенту проще просто скачать поток байтов.
Можно было бы попытаться сделать один endpoint и переключать формат через заголовок Accept, но для учебного проекта это слишком рано и слишком легко превращается в “магический контракт”. Поэтому мы выбираем честную и ясную модель: один URI для metadata, другой — для содержимого.
И вот здесь как редкое исключение появляется суффикс действия: .../download. Он не превращает API в RPC-каталог, потому что остаётся “привязанным” к конкретному ресурсу вложения и живёт в самом конце пути.
Сравните два подхода:
# Плохо: верхнеуровневая команда и query вместо адреса ресурса
GET /downloadAttachment?attachmentId=...
# Хорошо: нормальная адресация ресурса + узкое уточнение представления данных
GET /api/v1/tasks/{taskId}/attachments/{attachmentId}/download
Во втором варианте вы всё ещё адресуетесь к attachment через его нормальный ресурсный путь, а /download всего лишь уточняет, что вы хотите не metadata, а content. Это очень важно: исключение не ломает стиль, потому что не становится самостоятельным “верхнеуровневым действием”.
Критерий, который помогает не злоупотреблять такими суффиксами, звучит так: если вы добавили /download, спросите себя, это “бизнес-команда” или “альтернативное представление данных”? Для attachments это второе. Для /complete, /archive, /changeStatus — это первое, и это уже тревожный звоночек.
Если через месяц вы обнаружите в проекте пути вроде /start, /stop, /approve, /reject, /recalculate — поздравляю, вы незаметно построили RPC. /download хорош именно тем, что остаётся редким и объяснимым.
6. Мини-алгоритм и таблица URI
Чтобы не спорить о каждом новом URI, полезно держать короткий алгоритм выбора. Когда команда обсуждает новый путь, спор обычно выглядит так: один человек хочет “как удобнее сейчас”, другой — “как правильно по REST”, третий просто хочет домой. Чтобы не превращать каждое такое обсуждение в вечный сериал, полезно прогонять в голове короткий алгоритм до того, как вы напишете новый endpoint.
Ниже — упрощённая схема, которая подходит для нашего учебного проекта. Она не претендует на RFC-идеальность, но практична и помогает держать стиль API ровным.
flowchart TD
A["Нужно добавить новый endpoint"] --> B{"Мы работаем с ресурсом?"}
B -->|Нет| Z["Скорее всего, модель ресурсов выбрана плохо. Сначала найдите ресурс."]
B -->|Да| C{"Результат операции — новый объект с новым id?"}
C -->|Да| D["Сделайте subresource коллекцию и используйте POST пример: /api/v1/tasks/{taskId}/comments"]
C -->|Нет| E{"Это изменение состояния существующего ресурса?"}
E -->|Да| F["Остаёмся на URI ресурса пример: /api/v1/tasks/{taskId}, а действие выражаем методом и телом"]
E -->|Нет| G{"Это другое представление того же ресурса (metadata vs content)?"}
G -->|Да| H["Допустим action-suffix как узкое исключение пример: .../download"]
G -->|Нет| Y["Остановитесь и перепроверьте. Возможно, вам нужен другой ресурс или вы смешали разные ответственности."]
А теперь — таблица “в лоб”. Она часто помогает быстрее, чем длинные объяснения.
| Бизнес-смысл | Плохой (командный) вариант | Хороший (ресурсный) вариант | Почему хороший |
|---|---|---|---|
| Завершить задачу | POST /api/v1/completeTask | с status=DONE |
Адресуем задачу, меняем её состояние, не плодим команды |
| Поменять статус | POST /api/v1/changeStatus | с status=... |
Статус — часть состояния ресурса |
| Добавить комментарий | POST /api/v1/addCommentToTask | POST /api/v1/tasks/{taskId}/comments | Комментарий — новый ресурс (подресурс) |
| Прикрепить файл | POST /api/v1/uploadFileToTask | POST /api/v1/tasks/{taskId}/attachments | Вложение — новый ресурс (подресурс) |
| Скачать файл | GET /api/v1/downloadAttachment | GET /api/v1/tasks/{taskId}/ attachments/{attachmentId}/download | Узкое исключение ради другого представления данных (бинарное содержимое vs JSON) |
Обратите внимание: в хороших вариантах почти везде остаются одни и те же существительные: tasks, comments, attachments. Это и есть главный маркер “здорового” API: вы не сочиняете каждый раз новый язык команд, а просто комбинируете ресурсы и методы.
7. Типичные ошибки при проектировании URI
Эта тема кажется простой, пока вы не сделаете третий-четвёртый “удобный” командный endpoint, а потом не попытаетесь объяснить API новичку (или самому себе через два месяца). Ошибки здесь обычно не синтаксические, а архитектурные: всё компилируется, всё “работает”, но контракт становится всё менее предсказуемым. Ниже — самые частые грабли, на которые наступают даже хорошие разработчики.
Ошибка №1: делать отдельный endpoint на каждое действие, потому что так “быстрее”.
Когда вы добавляете /completeTask, вам действительно быстрее: один новый путь, один метод на сервере. Но потом появляются /archiveTask, /unarchiveTask, /assignTask, /unassignTask, /blockTask, /unblockTask, и API превращается в список заклинаний. В ресурсной модели многие из этих действий — всего лишь изменения состояния в одном и том же ресурсе.
Ошибка №2: прятать команды в query-параметры.
Иногда разработчик думает: “Ну ладно, глагол в пути — плохо. Сделаю так: POST /api/v1/tasks/{taskId}?action=complete”. К сожалению, это тот же RPC, только в плаще-невидимке. URI всё равно перестаёт быть адресом ресурса и превращается в команду. В хорошем контракте query обычно отвечает за фильтрацию/настройку чтения, а не за выполнение операций над состоянием.
Ошибка №3: использовать GET для операций с побочными эффектами.
Кажется удобным: “открыл в браузере — и задача завершилась”. Потом кто-то случайно поставил ссылку в чат, бот предпросмотрел страницу, браузер повторил запрос при обновлении — и “завершение задач” стало случайным фоном вашей жизни. GET должен быть безопасным (safe), а любые изменения состояния должны идти через методы записи.
Ошибка №4: превращать редкое исключение (вроде /download) в основной стиль API.
/download уместен как суффикс, когда вы отдаёте другое представление данных (бинарное содержимое вместо JSON metadata). Но если вы начнёте делать /complete, /archive, /changeStatus как суффиксы, вы снова соберёте каталог команд, только уже “вложенный” в ресурсы. Исключения должны быть редкими, иначе это уже не исключение, а ваш новый стиль.
Ошибка №5: смешивать два стиля в одной и той же ресурсной зоне.
Хуже всего выглядит гибрид: половина операций сделана ресурсно (PATCH /tasks/{taskId}), а половина командно (POST /tasks/{taskId}/complete, POST /tasks/{taskId}/changeStatus). Клиенту трудно понять, “как тут принято”. Если вы выбрали ресурсную модель, держите её последовательно: ресурс один, операции выражаются методами и телом, а специальные суффиксы оставляйте только там, где они действительно про представление данных (как download для attachments).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ