Опирается на правила: R-VLD-OAS-1, R-VLD-OAS-4, R-VLD-OAS-6 и R-VLD-OAS-X4, R-VLD-OAS-X5 из Validation Style Guide → раздел 6. Контракт-схема как источник правды.

Важно знать

  • Code-first — инверсия Java. В FastAPI Pydantic-модель — источник правды; OpenAPI (/openapi.json) генерируется из неё. В Java наоборот: YAML → generated DTO. Суть R-VLD-OAS-1 в обоих случаях одна — правило живёт в одном месте.
  • Модель в сигнатуре = контракт. Достаточно объявить req: CreateOrderRequest в параметрах эндпоинта — FastAPI провалидирует его до тела хендлера через Pydantic.
  • Nested-модели валидируются рекурсивно без дополнительных инструкций (R-VLD-WHERE-4). Вложенный класс объявляется отдельной Pydantic-моделью.
  • Inbound DTO — Pydantic-модель, не dict (R-VLD-OAS-X5). Request.json() или body: dict обходит всю систему типов и валидации.
  • После маппинга в UseCase-команду повторная валидация не делается (R-VLD-OAS-6). Команда приходит уже чистой.
  • Дублировать правила — Pydantic и ручной чек — запрещено (R-VLD-OAS-X4). Если Field(ge=0) есть на модели, if req.amount < 0 в хендлере — избыточен.
  • Custom-правило, не сводимое к стандартным Field-параметрам,Annotated-тип в backend/validation/types.py, не inline в DTO.

Раздел 6 нормативного контракта описывает принцип «один источник правды по входной валидации». Для Python/FastAPI это означает: правило объявлено в Pydantic-модели ровно один раз, оттуда же строится OpenAPI-схема, которую видит клиент. Ни YAML отдельно, ни ручных проверок поверх.

Модель = контракт

R-VLD-OAS-1, R-VLD-OAS-4: правила входа — в Pydantic-модели; контроллер принимает типизированный объект, не dict.

# backend/order/api/request.py
from uuid import UUID
from decimal import Decimal
from pydantic import BaseModel, Field, EmailStr
from backend.validation.types import RussianPhone


class OrderItemRequest(BaseModel):
    product_id: UUID
    quantity: int = Field(gt=0, le=1000)
    unit_price: Decimal = Field(gt=Decimal("0"), decimal_places=2)


class CreateOrderRequest(BaseModel):
    customer_id: UUID
    email: EmailStr
    phone: RussianPhone | None = None
    items: list[OrderItemRequest] = Field(min_length=1, max_length=100)


# backend/order/api/router.py
from fastapi import APIRouter
from .request import CreateOrderRequest

router = APIRouter()

@router.post("/v1/orders", status_code=201)
async def create_order(req: CreateOrderRequest, dispatcher: UseCaseDispatcher) -> OrderResponse:
    command = _to_command(req)
    order_id = await dispatcher.dispatch(command)
    return OrderResponse(id=order_id)

FastAPI вызывает Pydantic до тела хендлера. Невалидный запрос → RequestValidationError → 400 problem+json (cross-ref R-ERR-MAP-2). Хендлер не вызывается вообще.

OpenAPI, доступный на /openapi.json, строится из этих же классов: quantity отразится как "exclusiveMinimum": 0, "maximum": 1000, email — как "format": "email". Документация и реализация не расходятся по определению.

Маппинг Field-параметров в OpenAPI-схему

R-VLD-STD-2, R-VLD-STD-3, R-VLD-STD-5: знание соответствия позволяет читать схему и диагностировать, что попадёт в OpenAPI.

Pydantic / типOpenAPI-эквивалентЧто проверяется
field: str (без default)required, "type": "string"обязательно
field: str \| None = Noneне в required, nullable: trueнеобязательно
Field(min_length=1)"minLength": 1непустая строка
Field(max_length=200)"maxLength": 200максимальная длина
Field(ge=0)"minimum": 0число ≥ 0
Field(gt=0)"exclusiveMinimum": 0число > 0
Field(le=100)"maximum": 100число ≤ 100
Field(min_length=1, max_length=100) на list"minItems": 1, "maxItems": 100длина массива
EmailStr"format": "email"формат email
UUID"format": "uuid"парсинг при десериализации
datetime"format": "date-time"тип
date"format": "date"тип
Decimal"type": "number"деньги — не float
вложенная BaseModel$ref к схемерекурсивно
Annotated[str, AfterValidator(...)]тип строки (+ описание если задано)кастомная проверка

Несколько практических замечаний:

  • UUID и datetime парсятся Jackson-эквивалентом Pydantic при десериализации — невалидный формат ловится до любых валидаторов, клиент получает 422.
  • Decimal, не float для сумм: Decimal точен и поддерживает decimal_places в Field. float даёт погрешность представления и не несёт семантики «денежный тип».
  • Вложенная модель — отдельный BaseModel-класс, не dict. Только так Pydantic валидирует её поля и включает в OpenAPI-схему как $ref.

Nested-объекты и рекурсивная валидация

R-VLD-WHERE-4: вложенная Pydantic-модель валидируется автоматически — объявлять ничего не нужно.

# backend/product/api/request.py
from decimal import Decimal
from pydantic import BaseModel, Field


class DimensionsRequest(BaseModel):
    width_cm: Decimal = Field(gt=Decimal("0"), decimal_places=1)
    height_cm: Decimal = Field(gt=Decimal("0"), decimal_places=1)
    depth_cm: Decimal = Field(gt=Decimal("0"), decimal_places=1)


class CreateProductRequest(BaseModel):
    name: str = Field(min_length=1, max_length=255)
    sku: str = Field(min_length=1, max_length=50, pattern=r"^[A-Z0-9\-]+$")
    price: Decimal = Field(gt=Decimal("0"), decimal_places=2)
    dimensions: DimensionsRequest | None = None

Если dimensions передан, Pydantic проверит каждое его поле. Ошибки вложенных полей попадают в RequestValidationError.errors() с путём ["dimensions", "width_cm"] — клиент видит точную локацию.

Custom-правило через Annotated-тип

R-VLD-CC-1, R-VLD-CC-2, R-VLD-OAS-4: правило, не сводимое к стандартным Field-параметрам, — переиспользуемый Annotated-тип в backend/validation/types.py.

# backend/validation/types.py
import re
from typing import Annotated
from pydantic import AfterValidator

_RU_PHONE = re.compile(r"^\+7\d{10}$")
_INN_10 = re.compile(r"^\d{10}$")
_INN_12 = re.compile(r"^\d{12}$")


def _russian_phone(v: str) -> str:
    if not _RU_PHONE.match(v):
        raise ValueError("неверный формат телефона (ожидается +7XXXXXXXXXX)")
    return v


def _inn(v: str) -> str:
    if not (_INN_10.match(v) or _INN_12.match(v)):
        raise ValueError("ИНН должен содержать 10 или 12 цифр")
    return v


RussianPhone = Annotated[str, AfterValidator(_russian_phone)]
Inn = Annotated[str, AfterValidator(_inn)]

Применение в запросе:

# backend/customer/api/request.py
from backend.validation.types import RussianPhone, Inn


class RegisterCustomerRequest(BaseModel):
    full_name: str = Field(min_length=2, max_length=200)
    phone: RussianPhone
    inn: Inn | None = None
    email: EmailStr

Тип RussianPhone применяется в любом DTO без дублирования логики. Если завтра формат изменится — правим в одном месте.

Маппинг в UseCase-команду без повторной валидации

R-VLD-OAS-6: после маппинга в команду повторная валидация не делается. Команда — @dataclass или NamedTuple без Pydantic.

# backend/order/usecase/create_order.py
from dataclasses import dataclass
from uuid import UUID
from decimal import Decimal


@dataclass(frozen=True)
class CreateOrderItem:
    product_id: UUID
    quantity: int
    unit_price: Decimal


@dataclass(frozen=True)
class CreateOrder:
    customer_id: UUID
    email: str
    phone: str | None
    items: list[CreateOrderItem]

Маппер — чистая трансформация без проверок:

# backend/order/api/mapper.py
from .request import CreateOrderRequest
from ..usecase.create_order import CreateOrder, CreateOrderItem


def to_create_order_command(req: CreateOrderRequest) -> CreateOrder:
    return CreateOrder(
        customer_id=req.customer_id,
        email=req.email,
        phone=req.phone,
        items=[
            CreateOrderItem(
                product_id=item.product_id,
                quantity=item.quantity,
                unit_price=item.unit_price,
            )
            for item in req.items
        ],
    )

Хендлер получает готовую команду и сразу работает с доменом:

@router.post("/v1/orders", status_code=201)
async def create_order(req: CreateOrderRequest, dispatcher: UseCaseDispatcher) -> OrderResponse:
    order_id = await dispatcher.dispatch(to_create_order_command(req))
    return OrderResponse(id=order_id)

Доменные инварианты (Order должен иметь хотя бы один позиционный элемент) — в конструкторе агрегата Order, не в Pydantic и не в хендлере. Это отдельный концерн: R-VLD-WHERE-3, R-AGG-*.

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

АнтипаттернПравилоЧто взамен
body: dict или Request.json() для inbound DTOR-VLD-OAS-X5Pydantic-модель в сигнатуре эндпоинта
Дублирование: Field(ge=0) на модели и if req.amount < 0 в хендлереR-VLD-OAS-X4Правило в одном месте — в модели
@field_validator inline в файле DTO для переиспользуемой логикиR-VLD-CC-X2Annotated-тип в backend/validation/types.py
Pydantic-BaseModel для UseCase-команды (со встроенной валидацией)R-VLD-WHERE-X2@dataclass(frozen=True) без Pydantic
Ручная if-проверка в хендлере вместо Field-constraintR-VLD-WHERE-X1Декларативный Field(...) на модели
Нетипизированный вложенный объект (dict вместо nested BaseModel)R-VLD-WHERE-4Отдельный BaseModel-класс

Куда дальше

  • python/where-to-validate.md — где именно ставить проверки: Pydantic, агрегат, хендлер.
  • python/standard-constraints.md — полный разбор Field-параметров и типов-валидаторов.
  • python/custom-constraints.md — Annotated-типы, AfterValidator, model_validator.
  • python/cross-field-validation.md — @model_validator(mode="after") для правил на нескольких полях.
  • python/validation-groups.md — разные сценарии через отдельные модели вместо групп.
  • python/messages-and-i18n.md — тексты ошибок на русском, интерполяция.
  • python/configuration-validation.md — pydantic-settings BaseSettings, fail-fast на старте.
  • Error Handling Style Guide → Python — как RequestValidationError превращается в 400 problem+json.