Опирается на правила:
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 DTO | R-VLD-OAS-X5 | Pydantic-модель в сигнатуре эндпоинта |
Дублирование: Field(ge=0) на модели и if req.amount < 0 в хендлере | R-VLD-OAS-X4 | Правило в одном месте — в модели |
@field_validator inline в файле DTO для переиспользуемой логики | R-VLD-CC-X2 | Annotated-тип в backend/validation/types.py |
Pydantic-BaseModel для UseCase-команды (со встроенной валидацией) | R-VLD-WHERE-X2 | @dataclass(frozen=True) без Pydantic |
Ручная if-проверка в хендлере вместо Field-constraint | R-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.