Представьте: вы выпустили API, клиенты его используют, и вам нужно поменять формат ответа. Если просто изменить — все клиенты сломаются. Версионирование решает эту проблему: старые клиенты работают с v1, новые получают v2.
Версия в URL-пути
Самый распространённый способ — включить версию прямо в адрес:
/api/v1/orders
/api/v2/orders
В FastAPI это делается через APIRouter с префиксом:
from fastapi import FastAPI, APIRouter
v1_router = APIRouter(prefix="/api/v1", redirect_slashes=False)
v2_router = APIRouter(prefix="/api/v2", redirect_slashes=False)
app = FastAPI()
app.include_router(v1_router)
app.include_router(v2_router)
Несколько правил, которые помогают избежать путаницы:
- Версия — только целое число:
v1,v2,v3. Неv1.2, не2026. - Перед версией всегда
/api. Путь/v1/ordersбез/api— частая ошибка. - Версия — в пути, не в query-параметрах.
?version=1— плохой вариант: он ломает кеширование на шлюзах и усложняет маршрутизацию. - Версия — не в заголовке (
Accept-Version). Заголовки труднее тестировать и отлаживать.
Что выглядит правильно, а что нет:
/api/v1/orders ✓
/api/v2/orders ✓
/api/v1.2/orders ✗ — минорная версия не нужна
/api/2026/orders ✗ — дата вместо версии
/orders ✗ — нет /api и нет версии
/v1/orders ✗ — нет /api
Breaking и non-breaking изменения
Не каждое изменение API ломает клиентов. Важно различать два вида:
Non-breaking (безопасные) — клиенты продолжают работать без изменений:
- добавить необязательное поле в ответ;
- добавить необязательный query-параметр;
- добавить новый эндпоинт;
- добавить новое значение в перечисление (StrEnum);
- ослабить валидацию (увеличить максимальную длину строки).
Breaking (ломающие) — требуют новую версию, потому что клиент перестанет работать:
- удалить или переименовать поле (
order_id→id); - изменить тип поля (
str→int); - сделать параметр обязательным там, где он был необязательным;
- удалить эндпоинт или изменить его HTTP-метод;
- изменить URL-путь (
/orders→/sales-orders); - удалить значение из перечисления;
- ужесточить валидацию (уменьшить максимальную длину).
Главное правило: новая версия только при breaking change. Не нужно создавать v2 ради нового необязательного поля — добавьте его в текущую версию.
Forward compatibility: клиент игнорирует неизвестное
Чтобы non-breaking изменения действительно были безопасными, клиенты должны спокойно воспринимать поля, которых они не ожидали. FastAPI + Pydantic v2 делает это по умолчанию при десериализации.
Добавление необязательного поля в ответ — безопасная операция:
from pydantic import BaseModel
class OrderResponse(BaseModel):
order_id: str
status: str
channel: str | None = None # новое поле — безопасно добавить в текущую версию
Старые клиенты, которые не знают про channel, просто проигнорируют его.
То же правило работает для StrEnum. Добавление нового значения — безопасно, если клиент обрабатывает неизвестные значения:
from enum import StrEnum
class OrderStatus(StrEnum):
CREATED = "CREATED"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"
# CANCELLED = "CANCELLED" ← добавить — безопасно
Обратная ситуация: удаление значения из StrEnum — breaking change, потому что клиент, который ожидал это значение, получит что-то неожиданное.
Держать v1 и v2 одновременно
Когда breaking change всё же нужен, v1 не отключают сразу — оба роутера работают параллельно. Под капотом — одна бизнес-логика, разные Pydantic-схемы:
from fastapi import APIRouter, Depends
from . import schemas_v1, schemas_v2
from .use_cases import OrderUseCases
v1_router = APIRouter(prefix="/api/v1/orders", redirect_slashes=False)
v2_router = APIRouter(prefix="/api/v2/orders", redirect_slashes=False)
@v1_router.get("/{order_id}", response_model=schemas_v1.OrderResponse)
async def get_order_v1(order_id: str, use_cases: OrderUseCases = Depends()):
order = await use_cases.get_order(order_id)
return schemas_v1.OrderResponse.model_validate(order)
@v2_router.get("/{order_id}", response_model=schemas_v2.OrderResponse)
async def get_order_v2(order_id: str, use_cases: OrderUseCases = Depends()):
order = await use_cases.get_order(order_id)
return schemas_v2.OrderResponse.model_validate(order)
Порядок действий при breaking change:
- Создать
schemas_v2.pyс новым контрактом. - Добавить
v2_routerс новыми маршрутами. v1_routerпродолжает работать без изменений — клиенты мигрируют постепенно.- Пометить
v1устаревшим через заголовкиDeprecationиSunset. - После даты отключения вернуть
410 Gone.
Коротко
- Версия всегда в URL-пути:
APIRouter(prefix="/api/v1"). Формат —v+ целое число. - Путь начинается с
/api./v1/ordersбез/api— ошибка. - Новую версию делают только при breaking change: удаление или переименование поля, изменение типа, удаление эндпоинта.
- Non-breaking изменения (необязательное поле, новый эндпоинт, новое значение enum) — добавляют в текущую версию без
v2. - Pydantic v2 по умолчанию игнорирует неизвестные поля при десериализации — это и есть forward compatibility.
- При breaking change v1 не отключают сразу: оба роутера работают параллельно, за ними одна бизнес-логика, разные схемы.
Что почитать дальше
- URL и ресурсы — как строить пути, вложенность ресурсов, HTTP-методы на FastAPI.
- Ошибки RFC 9457 — расширение
ErrorCodeкак пример non-breaking изменения. - Rate limiting и deprecation — заголовки
Sunsetдля плавного отключения v1.