← назад к разделу

Представьте: вы выпустили 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_idid);
  • изменить тип поля (strint);
  • сделать параметр обязательным там, где он был необязательным;
  • удалить эндпоинт или изменить его 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:

  1. Создать schemas_v2.py с новым контрактом.
  2. Добавить v2_router с новыми маршрутами.
  3. v1_router продолжает работать без изменений — клиенты мигрируют постепенно.
  4. Пометить v1 устаревшим через заголовки Deprecation и Sunset.
  5. После даты отключения вернуть 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.