Начинают не с того: почему первый маршрут - это ловушка
Первое, что делают при проектировании REST API - рисуют первый маршрут. Обычно это что-то вроде /users или /tasks. Кажется логичным: нужно же с чего-то начать, а маршруты - это то, что видит клиент. Но этот подход ведёт в тупик.
Когда начинают с маршрутов, неизбежно возникает проблема дублирования логики. Например, задача может принадлежать и проекту, и пользователю. Тогда появляются маршруты /projects/{id}/tasks и /users/{id}/tasks. Клиенту приходится выбирать, какой из них использовать, а серверу - поддерживать оба. Это усложняет код и увеличивает вероятность ошибок.
Проблема в том, что маршруты - это лишь способ доступа к данным, а не сами данные. Если сначала определить сущности и их отношения, маршруты получатся сами собой. Например, если задача принадлежит проекту, то маршрут /projects/{id}/tasks будет единственным правильным решением.
Тупик: проектирование от экранов интерфейса
Самый распространённый (и ошибочный) подход - проектировать API от экранов интерфейса. На экране список задач - значит нужен /tasks. На экране создания задачи - значит нужен POST /tasks. Кажется, что это правильно: API должно обслуживать интерфейс.
Но этот подход не работает по нескольким причинам:
-
Интерфейс меняется чаще, чем данные. Если API жёстко привязан к экранам, каждое изменение интерфейса потребует изменений в API. Например, если на новом экране нужны задачи с фильтрами по статусу и исполнителю, придётся добавлять параметры
?status=open&assignee=123. А что, если позже понадобится другой фильтр? API начнёт разрастаться. -
Один и тот же экран может использовать разные данные. Например, экран задач может показывать задачи проекта, задачи пользователя или все задачи системы. Если API привязан к экрану, придётся создавать отдельные маршруты для каждого случая.
-
Разные клиенты могут использовать одни и те же данные по-разному. Мобильное приложение, веб-интерфейс и сторонний сервис могут по-разному отображать одни и те же данные. Если API привязан к одному клиенту, другим будет неудобно.
Вместо этого API нужно проектировать вокруг сущностей и их отношений. Например, задачи, проекты, пользователи. Маршруты должны отражать эти сущности, а не экраны интерфейса. Тогда API будет стабильным, даже если интерфейс изменится.
Коды ответа: почему “200 с ошибкой” - это минное поле
Коды HTTP-ответов - это не просто числа. Они управляют поведением всей инфраструктуры между клиентом и сервером: прокси, кэши, балансировщики, библиотеки клиентов.
Когда сервер возвращает 200 OK с телом { "error": "Not found" }, происходит следующее:
- Прокси видит код 200 и считает ответ успешным
- Прокси кэширует ответ
- Клиент получает кэшированный ответ с ошибкой
- Клиент не понимает, что ресурс не существует
Правильные коды ответов:
200 OK- успешный запрос с телом201 Created- успешное создание ресурса (с телом нового ресурса)204 No Content- успешный запрос без тела (например, удаление)400 Bad Request- клиентская ошибка (невалидные данные)401 Unauthorized- неавторизованный запрос403 Forbidden- доступ запрещён404 Not Found- ресурс не найден409 Conflict- конфликт состояний (например, дубликат)500 Internal Server Error- серверная ошибка
Код 200 с полем ошибки - это антипаттерн, который ломает всю цепочку обработки запросов. Если запрос не может быть выполнен, нужно возвращать соответствующий код 4xx или 5xx.
Форма ошибки: почему единообразие критично
Ошибки в API должны быть предсказуемыми. Клиент должен уметь обработать любую ошибку, не зная заранее, какая именно произойдёт.
Пример правильной структуры ошибки:
{
"error": {
"code": "not_found",
"message": "Task with ID 123 not found",
"details": {
"id": 123
}
}
}
Здесь:
code- машиночитаемый идентификатор ошибки. Он не меняется при изменении текста сообщения и позволяет клиенту программно обрабатывать ошибки.message- текст для человека. Он может меняться, но должен быть понятным.details- дополнительные данные об ошибке (например, ID несуществующего ресурса).
Если клиент видит code: "not_found", он знает, что ресурс не существует, и может предпринять соответствующие действия. Если же клиент видит только текстовое сообщение, ему придётся парсить его, что ненадёжно и ломко.
Изменения без поломок: правила эволюции API
API неизбежно меняется. Но изменения не должны ломать существующих клиентов.
Что можно делать:
- Добавлять новые поля в ответы. Клиенты должны игнорировать неизвестные поля.
- Добавлять новые маршруты. Существующие клиенты их просто не будут использовать.
Что нельзя делать:
- Переименовывать существующие поля. Это сломает клиентов, которые используют старые имена.
- Удалять поля без предупреждения. Если поле устарело, его нужно пометить как deprecated и удалить в следующей мажорной версии.
- Изменять тип существующих полей. Это сломает клиентов, которые ожидают определённый тип данных.
Где должна жить версия API:
- В пути:
/v1/tasks. Просто, но усложняет миграцию клиентов. - В заголовке:
Accept: application/vnd.company.v1+json. Гибко, но сложнее для тестирования.
Лучше избегать версионирования как можно дольше. Если API спроектирован правильно, большинство изменений можно внести без поломок.
Списки: как не утонуть в данных
Списки ресурсов - одна из самых сложных частей API. Если не продумать их заранее, позже придётся ломать клиентов.
Постраничная выдача
Постраничная выдача нужна всегда, когда список может быть большим. Есть два основных подхода:
-
Смещение (offset/limit):
- Простой в реализации
- Неэффективен для больших смещений
- Пример:
/tasks?offset=20&limit=10
-
Курсор:
- Эффективнее для больших объёмов данных
- Сложнее в реализации
- Пример:
/tasks?cursor=abc123&limit=10
Если вы не уверены, какой подход выбрать, начните со смещения. Его проще реализовать, и он подойдёт для большинства случаев.
Фильтры
Фильтры позволяют клиенту получать только нужные данные. Например:
/tasks?status=open- задачи со статусом “открыто”/tasks?assignee=123- задачи, назначенные пользователю с ID 123
Фильтры должны быть простыми и предсказуемыми. Не стоит делать сложные фильтры с логическими операциями - это усложняет реализацию и использование.
Сортировка
Сортировка позволяет клиенту получать данные в нужном порядке. Например:
/tasks?sort=due_date- сортировка по дате выполнения/tasks?sort=-priority- сортировка по приоритету (по убыванию)
Сортировка должна поддерживать как возрастающий, так и убывающий порядок.
Идемпотентность: почему клиент обязан уметь повторять запрос
Идемпотентность - это свойство операции, при котором повторное выполнение не меняет результат. Например, удаление задачи с ID 123 идемпотентно: если задача уже удалена, повторное удаление не изменит состояние системы.
Почему это важно:
- Сеть может потерять запрос
- Сервер может упасть во время обработки
- Клиент может не получить ответ
В таких случаях клиент обязан повторить запрос. Если операция не идемпотентна, повторный запрос может привести к нежелательным последствиям.
Как сделать операцию идемпотентной:
- Использовать идемпотентные методы HTTP (GET, PUT, DELETE)
- Добавлять идемпотентность на уровне бизнес-логики (например, использовать client_id для предотвращения дубликатов)
- Использовать заголовок Idempotency-Key
Права доступа: что остаётся за рамками API
Схема API определяет, какие данные доступны и как к ним обращаться. Но она не решает вопрос прав доступа к конкретной записи.
Например, пользователь может иметь доступ к списку задач (GET /tasks), но не к конкретной задаче (GET /tasks/123), если она принадлежит другому проекту.
Права доступа - это бизнес-логика, которая должна быть реализована на сервере. Клиент не должен знать, какие записи ему доступны - он просто делает запрос, а сервер решает, вернуть данные или ошибку 403 Forbidden.
Как реализовать права доступа:
- Проверка прав на уровне маршрута (например, при запросе GET /tasks/123)
- Фильтрация данных на уровне запроса (например, при запросе GET /tasks)
Позиция редакции
Проектирование REST API - это про контракты между сервером и клиентами. Контракт должен быть стабильным, предсказуемым и удобным для всех сторон.
Основные принципы:
- Сущности и их отношения должны определяться первыми. Маршруты - это лишь способ доступа к данным.
- Коды ответов - часть контракта. Неправильное использование кодов ломает всю инфраструктуру между клиентом и сервером.
- Форма ошибки должна быть единообразной. Клиент должен уметь обработать любую ошибку.
- Изменения API должны быть совместимыми. Добавлять можно, переименовывать нельзя, удалять - только с предупреждением.
- Списки должны поддерживать постраничную выдачу, фильтры и сортировку.
- Операции должны быть идемпотентными. Клиент должен уметь безопасно повторять запросы.
- Права доступа - это бизнес-логика, которая не решается на уровне API.
Эти принципы работают, когда API проектируется вокруг данных, а не вокруг интерфейса. Если API привязан к конкретным экранам, каждое изменение интерфейса будет требовать изменений в API, что ломает клиентов.
Условие, при котором позиция неверна: если API разрабатывается для внутреннего использования в небольшой команде, где интерфейс и API меняются синхронно. В этом случае можно проектировать API под конкретные экраны, но это не масштабируемый подход.