Опирается на правила:
R-VER-1..6иR-VER-X1..X4из REST API Style Guide → раздел Версионирование.
Важно знать
- Версия в URL:
APIRouter(prefix="/api/v1"). Формат —v+ целое число.- Префикс
/apiобязателен для всех бизнес-эндпоинтов.- Новая версия только при breaking change. Non-breaking — в текущей.
- Клиент обязан игнорировать неизвестные поля и enum-значения в ответе.
- Breaking: удалить/переименовать поле, изменить тип, удалить endpoint.
- Non-breaking: добавить optional поле в Pydantic-модель, новое значение StrEnum.
- Минорная версия (
v1.2) или дата-версия — запрещены.- Версия в query (
?version=1) — запрещена.
REST API — публичный контракт. FastAPI code-first: Pydantic-модели — источник правды, OpenAPI генерируется. Изменения в моделях сразу меняют контракт — нужно понимать, что breaking, а что нет.
Версия в URL-пути
R-VER-1..3:
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)
/api/v1/orders ✓
/api/v2/orders ✓ (breaking change → новая версия)
/api/v1.2/orders ✗ — минорная
/api/2026/orders ✗ — дата-версия
/orders ✗ — без /api, без версии
/v1/orders ✗ — без /api
Версия в query (?version=1) — R-VER-X2: ломает caching layer, сложнее routing на gateway.
Pydantic и forward compatibility
R-VER-5: клиент обязан игнорировать неизвестные поля.
FastAPI + Pydantic v2 по умолчанию игнорирует лишние поля при десериализации:
from pydantic import BaseModel
class OrderResponse(BaseModel):
order_id: str
status: str
# Новое поле — добавляем в текущей версии (non-breaking)
channel: str | None = None
R-VER-6: добавление optional поля (channel: str | None = None) — non-breaking. Существующие клиенты, которые не знают про channel, проигнорируют его.
Для StrEnum — добавление нового значения non-breaking при условии, что клиент обрабатывает unknown:
from enum import StrEnum
class OrderStatus(StrEnum):
CREATED = "CREATED"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"
# RESERVED = "RESERVED" ← добавление — non-breaking
Breaking vs non-breaking — таблица
Breaking (требуют v2)
| Изменение | FastAPI/Pydantic |
|---|---|
| Удаление endpoint | убрать @router.get |
| Удаление/переименование поля | order_id → id в BaseModel |
| Обязательный новый параметр запроса | required=True query |
| Изменение типа поля | str → int |
| Удаление значения из StrEnum | убрать константу |
| Изменение HTTP-метода | @router.get → @router.post |
| Изменение URL-пути | /orders → /sales-orders |
| Ужесточение валидации | Field(max_length=100) → max_length=50 |
Non-breaking
| Изменение | FastAPI/Pydantic |
|---|---|
| Необязательное новое поле в ответе | field: str | None = None |
| Необязательный новый query-параметр | Query(default=None) |
| Новое значение в StrEnum | добавить константу |
| Новый endpoint | новый @router.* |
| Новый error code в enum | добавить в ErrorCode |
| Ослабление валидации | увеличить max_length |
Изменение текста в detail ошибки | в exception handler |
R-VER-X4: новую версию не создаём для добавления optional поля — добавляется в текущую Pydantic-модель.
Параллельная поддержка v1 и v2
from fastapi import APIRouter
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)
Под капотом — одни use-cases, разные Pydantic-схемы. v1 и v2 — разные маршруты с разными response_model.
Когда нужна breaking change:
- Создать
schemas_v2.pyс новым контрактом. - Добавить
v2_routerс новыми маршрутами. v1_routerпродолжает работать без изменений.- Пометить
v1deprecated —Deprecation/Sunsetheaders (см. Rate limiting, файлы, deprecation). - После
Sunset—410 Gone.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
prefix="/api/v1.2" | R-VER-X1 | целое число (v2) |
prefix="/api/2026" | R-VER-X1 | v1, v2 |
Версия в query ?version=1 | R-VER-X2 | в URL path |
Endpoint без /api prefix | R-VER-X3 | APIRouter(prefix="/api/v1") |
| Endpoint без версии | R-VER-X3 | v1 обязательно |
| Новая версия для optional поля | R-VER-X4 | field: str | None = None в текущей |
| Удаление поля без новой версии | R-VER-6 | breaking → v2 |
Header versioning Accept-Version | R-VER-1 | path versioning |
failfast=True / model_config(extra='forbid') в клиентских DTO | R-VER-5 | игнорировать unknown поля |
Куда дальше
- REST API → Версионирование (нормативно) — формулировки.
- URL и ресурсы —
APIRouter(prefix="/api/v1"). - Rate limiting, файлы, deprecation —
Sunsetдля v1. - JSON и формат ответов — optional поля,
exclude_none. - Ошибки RFC 9457 —
ErrorCodeenum расширение non-breaking.