Введение

Привет, Хабр! Каждый раз, создавая новый эндпоинт, я ловил себя на мысли: «А как назвать маршрут?». Казалось, что где-то есть законы и правила, которые помогают создавать API последовательно. Со временем я наткнулся на диаграммы по проектированию, прочитал книгу JJ Geewax — API Design Patterns (Джей‑Джей Гивакс), изучил рекомендации от крупных компаний и понял важную мысль: проектирование API — это такая же область знаний со своими принципами и стандартами.

Следование этим правилам даёт практический результат:

  • API становятся удобными для разработчиков благодаря единообразным паттернам и стандартам веба.

  • Бизнес‑логику и сам API легче переиспользовать.

  • Интерфейс остаётся понятным и предсказуемым для внешних клиентов.

Однако возникает проблема: стандарты есть, но они разные. Многие из них красивы на бумаге, но непонятно, как их применить в обычном CRUD‑приложении без сложной бизнес‑логики.

Цель статьи — дать компактную шпаргалку по проектированию API для простых CRUD‑сервисов и показать ход мыслей, который позволяет проектировать последовательно и осмысленно.


Часть 1. Всё начинается с домена и кода

В хорошем API сначала проектируются ресурсы и взаимодействие с ними. Если доменная модель и её операции ясны, HTTP‑слой становится простым и предсказуемым.

Минимальный словарь действий

Чтобы достичь ожидаемого результата, опираемся на пять базовых операций в бизнес‑логике:

  • GetResource — получить конкретный ресурс.

  • ListResources — получить коллекцию ресурсов (с фильтрами/сортировкой/пагинацией).

  • CreateResource — создать ресурс.

  • UpdateResource — изменить ресурс (частично или полностью).

  • DeleteResource — удалить ресурс.

Чего не должно быть: GetByOrganization, GetByUser, Insert, Upsert, DeleteAll, FindOrCreate, ArchiveOldResources и т. д.
Вся вариативность — в параметрах, а не в новых именах методов.

  • Единообразный нейминг делает навигацию в коде очевидной.

  • Фильтр вместо GetBy* убирает дубли и взрыв количества эндпоинтов.


Часть 2. Стандартные методы API

Это те методы, на которые стоит посмотреть в первую очередь.

Ресурс: Resource

Модель для примера:

{
  "id": "guid",
  "title": "string",
  "status": "active|archived|draft"
}

1) Создание

POST /v1/resources
Тело запроса:

{ "title": "New resource", "status": "draft" }

Ответ 201 + созданный ресурс:

{ "id": "guid", "title": "New resource", "status": "draft" }

2) Получение конкретного ресурса

GET /v1/resources/{id}
Ответ 200 + ресурс; 404 — не найден.

3) Получение всех ресурсов

GET /v1/resources
Ответ 200:

{
  "resources": [
    { "id": "guid", "title": "New resource", "status": "draft" }
  ]
}

Возвращаем объект, а не «голый массив» — так проще расширять контракт (метаданные, пагинация и т. д.).
При отсутствии ресурсов возвращаем 200 и пустую коллекцию.

4) Полная замена (replace)

PUT /v1/resources/{id}
Тело запроса — полное представление ресурса (всё, что должно остаться в состоянии после замены):

{ "title": "Updated", "status": "active" }

Коды ответа:

  • 201 — созданный ресурс (если позволяем клиенту создавать со своим идентификатором; иначе — 404).

  • 200 — актуальный ресурс (если он был и мы его заменили).

Если поле не прислано, оно должно принять значение по умолчанию.

5) Частичное обновление

PATCH /v1/resources/{id}
Тело запроса — только изменяемые поля:

{ "status": "archived" }

Ответ 200 — актуальный ресурс; 404 — не найден.

6) Удаление

DELETE /v1/resources/{id}
Ответ 204 (без тела).

Рекомендации по разделу

  • Всегда множественное число сущности в путях: /resources.

  • Для стандартных операций идентификатор — только в пути: /resources/{id}.

  • Возвращаем полный ресурс на POST/PUT/PATCH — так проще дебажить, тестировать и поддерживать.


Часть 3. Расширение стандартных методов

Теперь усилим базовые операции — без размножения эндпоинтов и версий.

Пагинация

Самая простая и распространённая — offset‑based. Параметры: skip, take (синонимы: offset, limit).

GET /v1/resources?skip=20&take=10 — пропускаем 20, берём 10.
Ответ:

{
  "resources": [
    { "id": "guid", "title": "..." }
  ],
  "total": 350
}
  • total в ответе помогает клиенту понять, есть ли смысл тянуть следующие страницы.

  • Сортируем результат хотя бы по дате добавления, если пользователь не указал иного, чтобы возвращать детерминированные данные.

  • Обязательно фиксируем максимальный take.

  • Если выборка нестабильна, огромна, с тяжёлой сортировкой — используем cursor‑based пагинацию.

См. также:

Фильтрация

Чтобы не уходить в парсинг, берём прямой путь: явные query‑параметры на каждый фильтр.

Суффиксы операторов:

  • _eq (по умолчанию, можно опустить), _ne, _lt, _gt, _lte, _gte

  • _in, _nin — для множеств

Для вложенных полей — «плоские» имена: meta_created_at_gte=...

Примеры:

GET /v1/resources?status_in=active,draft
GET /v1/resources?title_eq=Design
GET /v1/resources?meta_created_at_gte=2023-01-01&meta_created_at_lte=2023-06-01
  • Для фильтрации по вложенным массивам сложных объектов (например, resource.users=[{...}]) лучше не использовать query‑параметры, а реализовывать отдельный метод поиска с POST‑телом.

  • Неизвестные параметры — по умолчанию игнорируем.

  • Для полнотекстового поиска лучше использовать параметр search.

С продвинутой реализацией через параметр filter можно ознакомиться по ссылкам:

Сортировка

Простой вариант — не парсить единый orderby, а держать сортировки в явных переменных.
created_at_sort=asc|desc, title_sort=asc|desc. Отсутствие — поле не участвует.

Примеры:

GET /v1/resources?created_at_sort=asc
GET /v1/resources?created_at_sort=asc&title_sort=desc

Сортировка через один query‑параметр представлена по ссылкам:

ИМХО: использование orderby/filter — это парсинг и связка с внутренней моделью, что делать в каждом микросервисе накладно; явные переменные делают контракт очевидным и упрощают реализацию.

Мягкое удаление (soft delete)

Оставляем единую схему: статус удалённости храним в колонке, а не в новых таблицах.
Модель: добавляем поле deleted_at: string|null.

  • Удалить (в архив)DELETE /v1/resources/{id}204 (проставляем deleted_at).

  • ВосстановитьPATCH /v1/resources/{id}/restore200 (ставим deleted_at = null и возвращаем ресурс).

  • Список с удалённымиGET /v1/resources?include_deleted=true.

Ответ (пример):

{
  "resources": [
    { "id": "guid", "title": "...", "deleted_at": null },
    { "id": "guid", "title": "...", "deleted_at": "2024-11-12T10:36:15Z" }
  ],
  "total": 100
}
  • Полное удаление (подтверждаем явно)DELETE /v1/resources/{id}?force=true204. Полезно для «удалить из архива/навсегда».

Частичное извлечение полей (fields)

Простого решения нет: нужна разборка строки и динамическая сборка ответа. Если это действительно нужно — вы делаете что-то сложнее CRUD и стоит опираться на гайдлайны:

Примеры:

GET /v1/resources?fields=id,title
GET /v1/resources/{id}?fields=id,status

Ответ (для списка):

{
  "resources": [
    { "id": "guid", "title": "..." }
  ],
  "total": 123
}

Примеры «всё вместе»

Список с пагинацией, фильтрами, сортировкой и усечёнными полями

GET /v1/resources?skip=10&take=5&status_in=active,draft&created_at_sort=desc&fields=id,title&include_deleted=false
{
  "resources": [
    { "id": "guid1", "title": "Design Guide" },
    { "id": "guid2", "title": "API Patterns" }
  ],
  "total": 100
}

Мягкое удаление / восстановление / форс‑удаление

DELETE /v1/resources/{id}           → 204
PATCH  /v1/resources/{id}/restore   → 200 (тело)
DELETE /v1/resources/{id}?force=true → 204

Часть 4. Пользовательские методы и пакетные операции

Стандартных GET/POST/PUT/PATCH/DELETE хватает на ~80% CRUD. Оставшиеся ~20% — доменные действия, которые:

  • меняют состояние ресурса не как «полная/частичная замена» (пример: «подтвердить», «отменить», «применить скидку»);

  • запускают вычисление (пример: «посчитать тариф»);

  • не создают новый ресурс напрямую, но производят эффект (пример: «просмотреть ресурс»).

Правила

  • Глагол после {id}: POST /v1/resources/{id}/apply-discount.

  • Метод — чаще POST (есть побочный эффект).

  • Исключения: вычислительные методы и счётчики, которые не меняют состояние → GET.

Примеры пользовательских методов

Просмотр ресурса (есть побочный эффект записи в лог/метрику)

POST /v1/resources/{id}/view

Счётчики (без побочного эффекта)

Публичные:

GET /v1/resources/count
GET /v1/users/{user-id}/resources/count

Внутренние (на нескольких ресурсах):

GET /_internal/resources/count
GET /_internal/organizations/{organization-id}/resources/count
GET /_internal/organizations/{organization-id}/users/{user-id}/resources/count

Расчёт стоимости (вычисление, без изменения состояния)

POST /v1/resources/calculate

Тело:

{ "destination": "New York", "deliverySpeed": "express" }

Ответ 200:

{ "price": 42.50, "currency": "USD", "eta_days": 2 }

Применение скидки (мутация)

POST /v1/resources/{id}/apply-discount

Тело:

{ "discount_сode": "SUMMER21" }

Пакетные операции (batch)

Снижают количество запросов к API, позволяют согласованно менять несколько ресурсов и упрощают клиентскую логику.

Общие принципы

  • Однотипность внутри запроса: не смешиваем «создать» и «удалить».

  • Атомарность: либо всё, либо ничего.

  • Лимиты: ограничиваем количество элементов и возвращаем понятную ошибку 413 Payload Too Large с подсказкой.

Массовое чтение

Частый анти‑паттерн:

GET /v1/resources/batch?ids=1&ids=2&...

Риск — лимит длины URL при больших батчах. Лучше так:

POST /v1/resources/batch-get

Тело:

{ "ids": ["guid-1", "guid-2"] }

Ответ 200:

{
  "resources": [
    { "id": "guid-1", "title": "A", "status": "draft" },
    { "id": "guid-2", "title": "B", "status": "active" }
  ]
}

Массив без обёртки ограничивает нас — формат с объектом позволяет добавить флаги (idempotency, force и др.).

Массовое создание

POST /v1/resources/batch

Тело:

{
  "resources": [
    { "title": "A", "status": "draft" },
    { "title": "B", "status": "active" }
  ]
}

Ответ 201:

{
  "resources": [
    { "id": "guid-1", "title": "A", "status": "draft" },
    { "id": "guid-2", "title": "B", "status": "active" }
  ]
}

Batch‑обновление

PUT /v1/resources/batch

Тело:

{
  "resources": [
    { "id": "guid-1", "status": "active", "title": "API Patterns" },
    { "id": "guid-2", "status": "active", "title": "Design Guide" }
  ]
}

Batch‑удаление

POST /v1/resources/batch-delete

Тело:

{
  "ids": ["guid-1", "guid-2"],
  "force": false
}

Ответ 200.
Согласно RFC 9110 рекомендуется использовать POST с телом, вместо DELETE.


Часть 5. Критика

Стоит ли на POST/PUT/PATCH всегда возвращать весь ресурс, если сервер уже принял изменения и на клиенте есть актуальная версия?
Да. В обычных CRUD‑сервисах это упрощает жизнь: меньше дополнительных запросов, легче дебажить и тестировать.

Не упрёмся ли мы в лимиты по количеству/длине query‑параметров? Это не станет проблемой для фильтрации и сортировки?
Для типичного CRUD с десятком явных фильтров проблем нет. Когда критериев много — используем метод POST и передаём параметры в теле.


Заключение

Я намеренно не касался в статье тем идемпотентности, ETag, версионирования и других аспектов — о них написано достаточно в официальных гайдлайнах. Цель была показать ход мыслей и что 80% задач можно закрыть небольшим и понятным набором правил. Придерживаясь их, вы снижаете риски дублирования, облегчаете поддержку и оставляете простор для расширения. Остальные 20% случаев потребуют осознанных решений — и здесь полезно сверяться с индустриальными стандартами.

Если у вас есть рекомендации, как сделать эту статью ещё практичнее и доступнее — напишите в комментариях или мне в профиль. По мере накопления фидбэка дополню разделы.


Полезные ссылки

Комментарии (0)


  1. ArchDemon
    17.09.2025 13:14

    Когда критериев много — используем метод POST и передаём параметры в теле

    Какой-то странный REST у нас получается. Запрос - явный GET, но из-за длинных параметров (и невозможности передать параметры в теле) мы должны использовать POST.

    Тогда проще использовать POST для всего. И получим RPC (JSON-RPC)


    1. id_bones Автор
      17.09.2025 13:14

      Спасибо за комментарий! Цель статьи обозначить вектор проектирования, конкретная реализация в разных компаниях и командах может отличаться, всё зависит от контекста.

      Использование POST для всего подряд усложняет настройки кэширования, ссылки/закладки и другие оптимизации опирающиеся на rest стандарты


  1. NightBlade74
    17.09.2025 13:14

    Опять притащили коды статусов HTTP как коды ответов на запросы к уровню приложения.

    HTTP всегда должен отвечать 2хx, если запрос с точки зрения HTTP выполнен, 4хх - если ошибка со стороны клиента, 5хх - что-то не так на сервере.

    А вот в теле ответа быть уже статус ответа на сам REST запрос, причем желательно не просто одним числом, а поподробнее.

    А у вас программист, вызывающий сервис, потом голову будет ломать, что же он перепутал: эндпоинт или фильтр запроса и с многоэтажными матами пытаться парсить строки ответов, следующие за HTTP-статусами в заголовке. Не надо смешивать уровни OSI.