Опирается на правила: 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_idid в BaseModel
Обязательный новый параметр запросаrequired=True query
Изменение типа поляstrint
Удаление значения из 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:

  1. Создать schemas_v2.py с новым контрактом.
  2. Добавить v2_router с новыми маршрутами.
  3. v1_router продолжает работать без изменений.
  4. Пометить v1 deprecated — Deprecation / Sunset headers (см. Rate limiting, файлы, deprecation).
  5. После Sunset410 Gone.

Что запрещено

АнтипаттернПравилоЧто взамен
prefix="/api/v1.2"R-VER-X1целое число (v2)
prefix="/api/2026"R-VER-X1v1, v2
Версия в query ?version=1R-VER-X2в URL path
Endpoint без /api prefixR-VER-X3APIRouter(prefix="/api/v1")
Endpoint без версииR-VER-X3v1 обязательно
Новая версия для optional поляR-VER-X4field: str | None = None в текущей
Удаление поля без новой версииR-VER-6breaking → v2
Header versioning Accept-VersionR-VER-1path versioning
failfast=True / model_config(extra='forbid') в клиентских DTOR-VER-5игнорировать unknown поля

Куда дальше

  • REST API → Версионирование (нормативно) — формулировки.
  • URL и ресурсы — APIRouter(prefix="/api/v1").
  • Rate limiting, файлы, deprecation — Sunset для v1.
  • JSON и формат ответов — optional поля, exclude_none.
  • Ошибки RFC 9457 — ErrorCode enum расширение non-breaking.