URL design и versioning: как назвать ресурсы и пережить breaking changes
URL API — это публичный контракт. Один раз опубликовал /v1/users — будешь поддерживать пять лет. Поменяешь URL — сломаешь всех клиентов, кто на тебя интегрирован. Это не “как назвать функцию” в коде — это куда дороже. И именно поэтому большинство известных API застряли с уродливыми именами образца 2010 года: меняться нельзя.
В этом уроке разберёмся, как проектировать URL для REST API так, чтобы было читаемо, предсказуемо, масштабируемо, и какие четыре стратегии версионирования существуют. Каждая стратегия имеет цену — кто-то ломается реже, кто-то требует лучшей инфраструктуры. Junior DE должен понимать tradeoff-ы, чтобы не выстрелить себе в ногу первым же дизайн-решением.
Главное правило: nouns, not verbs
REST оперирует ресурсами (вспомним урок 1). URL — это identifier ресурса, не имя функции. Соответственно, URL — это существительные.
| Плохо (RPC-стиль) | Хорошо (REST-стиль) |
|---|---|
POST /createUser | POST /users |
GET /getUserById?id=123 | GET /users/123 |
POST /deleteUser?id=123 | DELETE /users/123 |
POST /user/update | PUT /users/123 или PATCH /users/123 |
GET /listAllOrders | GET /orders |
Глагол кодируется в HTTP методе (GET/POST/PUT/DELETE), а не в URL. Это и есть uniform interface.
Исключение: action-style операции, которые не вписываются в CRUD. Как назвать “перезагрузить сервер”? “Заархивировать заказ”? Один из паттернов:
POST /orders/123/archive # action как sub-resource
POST /orders/123:archive # gRPC-style двоеточие (Google API)
POST /orders/123/actions/archive # явный action namespace
Худший вариант — /archiveOrder?id=123. Лучший — POST /orders/123/archive. Это всё ещё nouns (есть ресурс “archive у заказа”), но допустимое отклонение.
Google API Design Guide использует двоеточие для custom actions: POST /v1/users/123:resetPassword. Это формально позволено в URL по RFC 3986, но странновато выглядит. Большинство индустрии использует sub-resource подход. Junior DE должен уметь читать оба варианта.
Иерархия: родитель-ребёнок
Если у тебя есть владение между ресурсами — отрази его в URL.
/users/{user_id}/orders # все заказы конкретного пользователя
/users/{user_id}/orders/{order_id} # конкретный заказ конкретного пользователя
/users/{user_id}/orders/{order_id}/items # позиции в заказе
Зачем:
- Понятно из URL, кто чей.
- Легко делать access control: пользователь Alice может видеть только
/users/alice/.... - Натурально для иерархических данных.
Когда НЕ стоит:
- Если ресурс может принадлежать разным владельцам — лучше плоский URL
/orders/{order_id}с фильтром?user_id={user_id}. - Если URL получается длиннее 4-5 уровней — это запах дизайна.
/users/123/orders/456/items/789/discount/details— это ад. Делай плоский ресурс.
Plural или singular?
Соглашение в индустрии: plural для коллекций и одиночных элементов в коллекции. Singular для уникальных ресурсов без множественной формы.
/users # коллекция -- plural
/users/123 # один пользователь из коллекции -- всё ещё plural
/me # текущий пользователь, один -- singular
/profile # профиль текущего пользователя, один -- singular
/account # аккаунт текущего пользователя, один -- singular
Почему plural: консистентность. URL /users/123 читается как “пользователь номер 123 из коллекции users”. URL /user/123 создаёт впечатление, что есть много “user” сущностей.
Не смешивай: в одном API не должно быть /users и /order (один plural, другой singular). Будь последователен.
Query parameters: фильтры, сортировка, пагинация
Query strings (?key=value&key2=value2) — для необязательных параметров запроса: фильтры, сортировка, пагинация, выбор полей.
# Фильтрация
GET /users?role=admin
GET /users?status=active&country=RU
GET /orders?created_after=2026-01-01&status=paid
# Сортировка (соглашение: minus для desc)
GET /users?sort=name # по имени asc
GET /users?sort=-created_at # по created_at desc
GET /users?sort=country,-age # по country asc, потом age desc
# Пагинация (offset/limit)
GET /users?offset=20&limit=10
# Или page-based
GET /users?page=2&per_page=10
# Или cursor-based (для больших коллекций -- идемпотентнее)
GET /users?cursor=eyJpZCI6MTAwfQ&limit=20
# Sparse fieldsets (выбор подмножества полей -- экономит трафик)
GET /users?fields=id,name,email
Query parameters не должны быть обязательными для идентификации ресурса. /users?id=123 — плохо: id это идентификатор, должен быть в path -> /users/123. Query — это про “как выбрать/отфильтровать/отсортировать”, не про “что выбрать”.
Versioning: четыре стратегии
Режимы совместимости схем в Kafka Релиз-цикл, API versioning, deprecation в KubernetesРано или поздно тебе придётся сделать breaking change в API: переименовать поле, поменять тип, удалить endpoint. Чтобы не ломать клиентов одномоментно — нужен versioning. Четыре стратегии в индустрии.
Стратегия 1: URL path versioning
GET /v1/users/123
GET /v2/users/123
Плюсы:
- Очевидно из URL, какая версия.
- Тривиально маршрутизировать на разные backend-ы (
/v1/*-> старый сервис,/v2/*-> новый). - Кэшируется чисто (разные URL — разные cache entries).
Минусы:
- URL непостоянны при upgrade — клиент должен переписать все references при переходе.
- Технически нарушает REST: один resource не должен иметь два URL (но в индустрии это закрывают глаза).
Кто использует: Stripe (/v1/...), GitHub (/v3/... через subdomain), Twilio. Самая популярная стратегия в индустрии.
Стратегия 2: Query parameter versioning
GET /users/123?version=1
GET /users/123?version=2
GET /users/123?api-version=2026-05-01
Плюсы:
- URL ресурса стабилен.
- Удобно для постепенной миграции — можно дефолтить если параметр не указан.
Минусы:
- Query parameter может быть забыт — поведение зависит от дефолта (часто меняется).
- Грязнее в кэшировании (нужно кэшировать с учётом query).
Кто использует: Azure (api-version=2024-08-01). Дата вместо версии — ещё один паттерн (rolling versioning).
Стратегия 3: Accept header versioning (media type)
GET /users/123
Accept: application/vnd.example.v1+json
GET /users/123
Accept: application/vnd.example.v2+json
Плюсы:
- “Чистый” REST: один resource, один URL, разные representations.
- Версия — это часть representation, что концептуально правильно.
Минусы:
- Тяжелее тестировать в браузере (надо ставить header, а в адресной строке этого не сделаешь).
- Сложнее для junior разработчиков и debugging.
Кто использует: GitHub (Accept: application/vnd.github.v3+json), Salesforce. Второй по популярности подход.
Стратегия 4: Custom header
GET /users/123
X-API-Version: 2
Плюсы:
- URL чистый, версия отдельно.
- Легко изменить через middleware.
Минусы:
- Не стандартизировано — каждый API называет header по-своему.
- Похоже на Accept-стратегию, но без официального RFC backing.
Кто использует: небольшие API. Не самая распространённая.
Сравнительная таблица
| Стратегия | Пример | URL стабилен | Cache-friendly | ”Чистый” REST | Browser-friendly |
|---|---|---|---|---|---|
| URL path | /v1/users | нет | да | нет | да |
| Query param | /users?v=1 | да | средне | средне | да |
| Accept header | Accept: ...v1+json | да | нужен Vary | да | нет |
| Custom header | X-API-Version: 1 | да | нужен Vary | средне | нет |
Для header-based versioning сервер ОБЯЗАН выставлять Vary: Accept (или Vary: X-API-Version) в ответе. Иначе CDN/proxy закэширует ответ для одной версии и отдаст его клиентам с другой версией. Это распространённый источник багов.
Когда вообще нужно версионировать
Не каждое изменение требует новой версии. Различай:
Backward-compatible (без новой версии):
- Добавление новых полей в response — старые клиенты их игнорируют.
- Добавление новых необязательных параметров запроса.
- Добавление новых endpoint-ов.
- Расширение enum-а (но осторожно — клиенты часто плохо обрабатывают неизвестные значения).
Breaking changes (нужна новая версия):
- Удаление или переименование поля.
- Изменение типа поля (
int->string). - Изменение формата дат.
- Удаление endpoint-а или метода.
- Изменение семантики статус-кода (
200->204). - Сужение enum-а.
Стратегия “не версионировать вообще, только добавлять” работает на удивление долго. Stripe держал /v1/ 14 лет (с 2011) и не выпустил /v2/. Они только меняют поведение по Stripe-Version header (rolling versions).
Попробуй сам: проанализируй URL у популярных API
import requests
# GitHub: URL path + Accept header
r = requests.get(
"https://api.github.com/users/torvalds",
headers={"Accept": "application/vnd.github.v3+json"}
)
print(r.url) # version в Accept header
# Stripe: URL path
r = requests.get(
"https://api.stripe.com/v1/charges",
auth=("sk_test_...", "")
)
print(r.url) # https://api.stripe.com/v1/charges
# Сравни структуру URL у GitHub
# /repos/{owner}/{repo}/issues -- иерархия repos -> issues
# /repos/{owner}/{repo}/issues/{n} -- конкретный issue
# /search/repositories?q=... -- search как отдельный resource
# Twilio
# /2010-04-01/Accounts/{sid}/Messages -- date-based versioning в URL
Открой документацию двух разных API и составь таблицу: какая стратегия URL, какая versioning, есть ли иерархия. Учись видеть design decisions.
DE-контекст: типичные ошибки
- Hardcoded URL versions:
BASE_URL = "https://api.x.com/v1"в коде — поломаешься при переходе на v2. Лучше вынестиAPI_VERSIONв конфиг. - Игнорирование deprecation headers: API часто шлёт
Deprecation: trueилиSunset: Fri, 31 Dec 2026 23:59:59 GMT. Логируй такие предупреждения, иначе пропустишь окно миграции. - Pagination в Airflow tasks: offset-пагинация может терять/дублировать данные если коллекция меняется во время скачивания. Cursor-based — стабильнее, всегда выбирай его если API даёт.
- Query string в логах: не логируй полный URL если в query есть
api_key=.... Маскируй или используй headers для секретов.
Killer takeaway
URL это nouns, не verbs — глагол в HTTP методе. Используй plural (/users) для коллекций, singular (/me) для уникальных ресурсов. Иерархия в URL отражает владение, но не глубже 3-4 уровней. Query parameters — для фильтров/сортировки/пагинации, не для идентификации ресурса. Versioning: четыре стратегии. URL path (/v1/) — самая популярная и удобная для cache. Accept header (vnd.x.v1+json) — самая “RESTful”. Query (?v=1) и custom header — нишевые. Не версионируй для backward-compatible изменений (новые поля, новые endpoint-ы). Версионируй для breaking (удаление, переименование, смена типа).