open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 06 · 02

Pydantic v2: парсинг на границе, доверие внутри — коэрция, валидаторы и фильтрация response_model

BaseModel превращает dict в типизированный объект или 422 — парсить, а не проверять. Rust-ядро стоит нс–мкс на поле (заметно на списках в 10 тыс. элементов). Lax-коэрция прячет дрейф контракта, валидаторы несут межполевые правила, response_model глушит утечки полей.

PY Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Биллинговый сервис честно типизировал денежное поле: cents: int. Выше по течению партнёрская интеграция регресснула и начала сериализовать деньги строками — "8999" вместо 8999. Ничего не упало. Дефолтный lax-режим pydantic молча коэрцировал цифровую строку в int на каждом запросе, пять месяцев подряд: контракт уплыл, а сигнализации, способной это заметить, не существовало. Потом партнёр локализовал свой форматтер, европейская локаль выдала "89,99" — и первый час крупнейшей распродажи года превратился в стену 422-х на платёжном пути. Самая острая строка постмортема была не про запятую — про пять тихих месяцев: граница принимала payload-ы, нарушавшие контракт, и именно та фича, что делала интеграцию гладкой (коэрция), прятала дрейф. Команда оставила lax для query-параметров, перевела платёжные модели в strict и вписала в ранбук одно честное предложение: слой валидации, который никогда ничего не отклоняет, не валидирует.

После этого урока ты будешь знать, когда доверять коэрции pydantic, а когда она тихо прячет уплывший контракт — и у тебя будет структурный ответ как на утечку на входе, так и на утечку на выходе.

Парсить, а не проверять

Зачем вообще разделять эти два подхода? Слой, который проверяет, оставляет бизнес-коду вопрос «а точно ли это int или строка всё-таки просочилась?» — слой, который парсит, делает этот вопрос бессмысленным. Философия pydantic в том, что граница не должна проверять данные — она должна потреблять нетипизированный вход и производить типизированный объект, либо громко падать. После успешного Order.model_validate(payload) каждый потребитель ниже по течению держит Order, чьи поля гарантированно int, str, datetime — без оборонительных isinstance, без веток «а вдруг None» в бизнес-логике. В FastAPI парсинг встроен в сигнатуру эндпоинта, а отказ автоматический:

from pydantic import BaseModel

class OrderIn(BaseModel):
    sku: str
    quantity: int
    cents: int

@app.post("/orders")
async def create_order(order: OrderIn):   # распарсено до вашего кода
    charge(order.cents * order.quantity)  # int-ы, гарантированно

Payload, который не парсится, до хендлера не доходит — клиент получает 422 с машиночитаемым списком ошибок (loc, msg, type на каждый отказ). Цена честная и небольшая: валидация pydantic v2 выполняется в pydantic-core, компилированном Rust, за наносекунды-микросекунды на поле — маленькая модель парсится за ~1–2 мкс. Заметной она становится на объёме: список из 10 тыс. вложенных моделей — это десятки миллисекунд, реальные деньги p99 на горячем эндпоинте. Сначала измеряйте, потом обвиняйте — но и не делайте вид, что граница бесплатна.

Lax против strict: где коэрция спасает, а где врёт

По умолчанию pydantic валидирует в lax-режиме по документированной таблице преобразований: строка "1" становится числом 1, "true"True, целочисленный float 89.0 проходит в поле int. Это сознательно — веб строково типизирован (query-параметры, поля форм, переменные окружения приходят строками), и lax-режим это поглощает. Режим отказа — Хук: коэрция не просто принимает неряшливый вход, она скрывает сам факт его неряшливости. Уплывший контракт продолжает валидироваться до того дня, когда дрейф породит нечто некоэрцируемое — "89,99", — и тогда он падает в худший возможный момент вместо первого возможного. Ручка крутится на уровне поля, модели или вызова:

class PaymentIn(BaseModel):
    model_config = ConfigDict(strict=True)   # int обязан БЫТЬ int-ом
    cents: int
    currency: str

# или на вызов, на выбранных границах:
PaymentIn.model_validate(payload, strict=True)

Сеньорский паттерн — асимметричная строгость: lax там, где строки структурны (query-параметры, заголовки, настройки), strict там, где типы и есть контракт (деньги, идентификаторы, всё, что сериализует партнёр). Межполевые правила живут в валидаторах — field_validator для одного поля, model_validator(mode="after") для инвариантов между полями («возврат не может превышать исходный платёж»). И у валидаторов одно железное правило: никаких побочных эффектов. Валидатор выполняется везде, где выполняется валидация, — парсинг запроса, сериализация через response_model, вызов model_validate в батч-джобе, — так что инкремент метрики или аудит-запись внутри него срабатывает по два-три раза на запрос и по разу на каждый реплей; именно так у команд появляются аудит-таблицы, не сходящиеся с реальностью.

Викторина

Модель объявляет cents: int (конфигурация по умолчанию). В теле запроса cents — строка '8999'. Что произойдёт и в чём сеньорская тревога?

Граница на выходе: response_model как обратный клапан

У валидации есть зеркальное отражение на выходе. Хендлер, возвращающий свой ORM-объект, возвращает всё, что на нём есть, — так и случается классический инцидент: у User появляется колонка hashed_password, эндпоинт возвращает user, и хеш уезжает каждому клиенту, пока кто-нибудь не вчитается в JSON. response_model — это клапан: FastAPI валидирует и сериализует возвращаемое значение через объявленную модель, и поля, не объявленные на ней, в ответе просто не существуют:

class UserPublic(BaseModel):
    id: int
    email: str
    model_config = ConfigDict(from_attributes=True)  # читать из ORM-объектов

@app.get("/users/{user_id}", response_model=UserPublic)
async def get_user(user_id: int):
    return await db.fetch_user(user_id)   # полная ORM-строка на входе, два поля на выходе

Фильтрация структурная, а не рекомендательная: она работает, даже когда функция возвращает более богатый объект, и продолжит работать, когда миграция следующего года добавит следующую чувствительную колонку. Та же дисциплина применима к конфигурации: pydantic-settings (библиотека для типизированного чтения переменных окружения) парсит переменные окружения через типизированную модель на старте — DATABASE_URL: PostgresDsn, TOKEN_TTL: timedelta, API_KEY: SecretStr (тип, чьё значение скрывается в repr как **********), — так что битый env роняет деплой на буте, а не первый запрос в три ночи, а секреты печатаются в логах безопасно.

Полиморфные payload-ы: размеченные объединения

Вебхуки и событийные API шлют много форм через одну дверь. Обычный Union[A, B, C, D] заставляет pydantic пробовать членов, пока один не подойдёт, — медленно, и хуже того: ошибка для плохого payload-а — невнятная куча отказов всех членов сразу. Размеченное объединение (discriminated union — объединение типов с явным полем-дискриминатором) называет поле-тег, и диспетчеризация становится одним поиском по словарю:

class PaymentSucceeded(BaseModel):
    type: Literal["payment.succeeded"]
    cents: int

class RefundCreated(BaseModel):
    type: Literal["refund.created"]
    cents: int
    original_id: str

Event = Annotated[PaymentSucceeded | RefundCreated, Field(discriminator="type")]

Неизвестный тег падает с одной точной ошибкой, указывающей на type, а не с четырёхсторонним завалом; стоимость валидации остаётся плоской при росте объединения. Для приёма событий на любом объёме дискриминатор — разница между ошибкой, по которой партнёр может действовать сам, и ошибкой, по которой он заведёт тикет.

Викторина

Хендлер возвращает полный ORM-объект User (включая hashed_password) из эндпоинта с response_model=UserPublic, где только id и email. Что получит клиент?

Вспомните перед уходом
  1. 01
    Объясните «парсить, а не проверять», lax против strict и биллинговый режим отказа, который делает возможным lax.
  2. 02
    Почему валидаторы обязаны быть без побочных эффектов, что реально делает response_model и когда брать размеченное объединение?
Итог

Граница парсит; внутренности доверяют. model_validate (или сигнатура FastAPI) превращает сырой dict в типизированный объект или в 422, чей список ошибок называет каждое провалившееся поле, — бизнес-логика ниже по течению никогда не видит полувалидных данных, и в этом весь смысл. Дефолтный lax-режим коэрцирует по документированной таблице — "1" в 1, "true" в True, — что поглощает строково типизированный веб и тем же движением прячет дрейф контракта: биллинг, пять месяцев принимавший строковые центы, узнал об этом через стену 422-х от "89,99" на пике распродажи. Строгость — это ручка: на поле, на модель, на вызов, и сеньорское использование асимметрично — strict там, где тип есть обещание, lax там, где строки структурны. Валидаторы несут межполевые инварианты и обязаны оставаться чистыми, потому что срабатывают везде, где срабатывает валидация, включая путь ответа и батч-джобы. На выходе response_model — структурный обратный клапан, отбрасывающий каждое необъявленное поле из любого объекта, который вернул хендлер, — постоянная защита от отгрузки hashed_password миру. pydantic-settings применяет ту же дисциплину парсинга-на-границе к окружению на буте, а размеченные объединения дают полиморфным вебхукам O(1)-диспетчеризацию по тегу с ошибками, которые партнёр способен прочитать. Машинерия написана на Rust и стоит микросекунды — честно, мало и заметно только на объёме в 10 тыс. элементов. Теперь, когда встретишь модель, молча принимающую строку вместо числа, ты знаешь вопрос, который нужно задать первым: сколько это уже длится — и какое некоэрцируемое значение это вскроет на пике нагрузки?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.