При проектировании надежного REST API проверка входящих данных — первая линия защиты сервиса. По умолчанию связка FastAPI и Pydantic v2 берет на себя парсинг и валидацию JSON, возвращая HTTP-статус 422 Unprocessable Entity при любых несоответствиях схеме. Однако в реальных проектах со сложными вложенными объектами, списками и строгими бизнес-правилами стандартного поведения часто недостаточно.
Неявное приведение типов (type coercion) может пропускать строки вместо чисел, кортежи путей loc в стандартном ответе сложны для парсинга на фронтенде, а системные сообщения Pydantic не всегда информативны для конечного пользователя. Разберем, как построить строгий, предсказуемый и тестируемый контракт валидации с помощью инструментов Pydantic v2 и FastAPI.
1. Проблема дефолтной валидации: почему стандартного 422 бывает недостаточно
Структура стандартного ответа RequestValidationError
Когда входящий JSON не проходит валидацию, FastAPI перехватывает исключение pydantic.ValidationError, преобразует его в fastapi.exceptions.RequestValidationError и отдает ответ со статусом 422. Типичное тело ответа выглядит так:
{
"detail": [
{
"type": "missing",
"loc": ["body", "items", 0, "sku"],
"msg": "Field required",
"input": {}
}
]
}
Для простых плоских структур этого хватает. Но когда клиент отправляет массив из десятков объектов со вложенными адресами и характеристиками, клиентским разработчикам приходится вручную парсить массив loc, разбираться, относится ли индекс к массиву или объекту, и сопоставлять технические сообщения Pydantic с полями пользовательских форм.
Неявное приведение типов (Coercion) и скрытые баги
По умолчанию Pydantic работает в гибком режиме (lax mode). Это означает, что он пытается привести входящие данные к целевому типу, если это технически возможно:
- Строка
"123"превратится в целое число123, если поле объявлено какint. - Строка
"true","yes","1"или число1превратятся вTrueдля поляbool. - Число с плавающей точкой
12.0превратится в12для поляint.
В ряде сценариев такое поведение приводит к логическим ошибкам. Например, строковый идентификатор заказа "0123", попав в поле int, станет числом 123 с потерей лидирующего нуля. Если API требует строгого контракта, неявное приведение типов необходимо принудительно отключать.
2. Строгая типизация: управление strict-режимом в Pydantic v2
Pydantic v2 позволяет включить строгий режим (strict mode) на уровне отдельных полей, специальных типов или всей модели целиком.
Field(strict=True) и типы Strict*
Для точечного запрета приведения типов используются типы StrictInt, StrictStr, StrictBool, StrictFloat либо параметр strict=True внутри функции Field:
from pydantic import BaseModel, Field, StrictInt, StrictStr
class ItemPayload(BaseModel):
# Строго строка: число или boolean вызовут ошибку валидации
sku: StrictStr
# Строго целое число: строка "10" вызовет ошибку
quantity: StrictInt
# Альтернативный синтаксис через Field
price_cents: int = Field(..., strict=True, gt=0)
Если клиент передаст {"sku": 100200, "quantity": "5", "price_cents": 150}, Pydantic не станет приводить типы и сразу вернет ошибку валидации для полей sku и quantity.
Включение strict-режима на уровне модели через ConfigDict
Если строгим должен быть весь контракт целиком, правило задается глобально для модели через model_config:
from pydantic import BaseModel, ConfigDict
class StrictOrderCreate(BaseModel):
model_config = ConfigDict(strict=True)
order_id: int
customer_code: str
is_urgent: bool
Все поля модели StrictOrderCreate будут требовать точного соответствия типов без попыток автоматического парсинга несовместимых значений.
3. Вложенные модели и коллекции: валидация сложных структур
В реальных API полезная нагрузка редко бывает плоской. Чаще всего запрос представляет собой дерево: заказ содержит данные покупателя, адрес доставки и список позиций, каждая из которых имеет свои атрибуты.
Типизация списков объектов: list[OrderItem]
В Pydantic v2 вложенность организуется через композицию классов и стандартную типизацию Python:
from pydantic import BaseModel, Field
class OrderItem(BaseModel):
sku: str = Field(..., min_length=3, max_length=30)
quantity: int = Field(..., gt=0)
unit_price: float = Field(..., gt=0)
class DeliveryAddress(BaseModel):
city: str
street: str
postal_code: str = Field(..., pattern=r"^\d{6}$")
class OrderCreateRequest(BaseModel):
customer_id: int = Field(..., gt=0)
address: DeliveryAddress
items: list[OrderItem] = Field(..., min_length=1)
FastAPI автоматически рекурсивно проверит:
- Корневой объект
OrderCreateRequest. - Вложенный объект
address. - Каждый элемент в массиве
items(а также проконтролирует наличие хотя бы одного элемента в списке).
Позиционирование ошибок в коллекциях: чтение кортежа loc
Если в массиве из 10 элементов ошибка допущена во втором объекте в свойстве unit_price, Pydantic сформирует в loc точный путь:
("body", "items", 1, "unit_price")
Где items — имя поля, 1 — индекс элемента в списке (нумерация с нуля), а unit_price — целевое невалидное свойство.
4. Бизнес-валидация: декораторы @field_validator и @model_validator
Встроенных проверок типов и ограничений Field (вроде gt, min_length, pattern) часто недостаточно. Для реализации бизнес-логики в Pydantic v2 используются декораторы @field_validator и @model_validator.
Одиночные поля с @field_validator
Декоратор @field_validator проверяет или нормализует значение конкретного поля:
from pydantic import BaseModel, field_validator
class UserRegisterRequest(BaseModel):
username: str
promo_code: str | None = None
@field_validator("username")
@classmethod
def validate_username(cls, value: str) -> str:
cleaned = value.strip().lower()
if not cleaned.isalnum():
raise ValueError("Имя пользователя должно содержать только буквы и цифры")
return cleaned
@field_validator("promo_code")
@classmethod
def normalize_promo(cls, value: str | None) -> str | None:
if value is not None:
return value.strip().upper()
return value
По умолчанию валидатор поля срабатывает после базовой проверки типов (mode='after'). Если требуется обработать сырые входные данные до приведения типов, используется аргумент mode='before'.
Кросс-валидация нескольких полей через @model_validator
Когда корректность одного поля зависит от значения другого (например, дата начала не может быть позже даты окончания), применяется @model_validator(mode='after'):
from datetime import date
from pydantic import BaseModel, model_validator
class BookingRequest(BaseModel):
start_date: date
end_date: date
guests_count: int
@model_validator(mode="after")
def verify_date_range(self) -> "BookingRequest":
if self.end_date <= self.start_date:
raise ValueError("Дата окончания бронирования должна быть строго позже даты начала")
return self
5. Единый контракт ошибок: кастомный обработчик RequestValidationError
Чтобы клиенты API получали предсказуемый ответ, стандартный перехватчик FastAPI переопределяется через кастомный exception_handler.
Преобразование кортежа loc в читаемую точечную нотацию
Вместо сырого массива ["body", "items", 1, "unit_price"] клиенту удобнее передавать путь формата items[1].unit_price.
Функция форматирования пути:
def format_loc(loc: tuple[int | str, ...]) -> str:
# Исключаем служебные префиксы из начала пути
parts = [part for part in loc if part not in ("body", "query", "path", "header", "cookie")]
if not parts:
return "__root__"
formatted_path = ""
for part in parts:
if isinstance(part, int):
formatted_path += f"[{part}]"
else:
if formatted_path:
formatted_path += f".{part}"
else:
formatted_path = str(part)
return formatted_path
Реализация кастомного Exception Handler
Зарегистрируем обработчик для RequestValidationError на уровне экземпляра FastAPI:
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel
app = FastAPI(title="Order API")
class ErrorDetail(BaseModel):
field: str
message: str
error_type: str
class ValidationErrorResponse(BaseModel):
status: str = "error"
errors: list[ErrorDetail]
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request,
exc: RequestValidationError
) -> JSONResponse:
errors_list: list[dict[str, str]] = []
for err in exc.errors():
field_path = format_loc(err.get("loc", ()))
message = err.get("msg", "Некорректное значение")
# Очистка стандартного префикса Pydantic при ValueError
if message.startswith("Value error, "):
message = message.replace("Value error, ", "")
errors_list.append({
"field": field_path,
"message": message,
"error_type": err.get("type", "validation_error")
})
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"status": "error",
"errors": errors_list
}
)
Теперь при отправке некорректного тела запроса клиент получит стандартизированный ответ:
{
"status": "error",
"errors": [
{
"field": "address.postal_code",
"message": "String should match pattern '^\\d{6}$'",
"error_type": "string_pattern_mismatch"
},
{
"field": "items[0].quantity",
"message": "Input should be greater than 0",
"error_type": "greater_than"
}
]
}
6. Тестирование валидационного контракта с pytest
Каждое правило валидации и контракт ошибок должны проверяться автоматическими тестами. Для этого используется pytest и TestClient из fastapi.testclient.
Пример тестового набора
import pytest
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create_order_success():
payload = {
"customer_id": 10,
"address": {
"city": "Москва",
"street": "Тверская, 1",
"postal_code": "125009"
},
"items": [
{"sku": "SKU-100", "quantity": 2, "unit_price": 499.90}
]
}
response = client.post("/orders", json=payload)
assert response.status_code == 200
assert response.json()["status"] == "created"
def test_validation_error_nested_and_indexed_fields():
invalid_payload = {
"customer_id": -1, # Невалидный ID (<= 0)
"address": {
"city": "Москва",
"street": "Тверская, 1",
"postal_code": "ABC" # Невалидный индекс (не 6 цифр)
},
"items": [
{"sku": "OK", "quantity": 0, "unit_price": 100.0} # quantity <= 0
]
}
response = client.post("/orders", json=invalid_payload)
assert response.status_code == 422
data = response.json()
assert data["status"] == "error"
errors = {item["field"]: item["message"] for item in data["errors"]}
# Проверяем корректность путей к полям
assert "customer_id" in errors
assert "address.postal_code" in errors
assert "items[0].quantity" in errors
7. Частые вопросы (FAQ)
В чем разница между strict=True в Pydantic v2 и дефолтным поведением?
По умолчанию Pydantic пытается безопасно преобразовать типы (например, строку "10" в число 10). В режиме strict=True приведение типов полностью запрещено: если поле ожидает int, а передана строка или float, валидация немедленно вернет ошибку.
Когда использовать @field_validator, а когда @model_validator?
@field_validator предназначен для проверки и трансформации одного конкретного поля (проверка формата строки, очистка пробелов, валидация e-mail). @model_validator используется для кросс-валидации, когда логика требует сравнения значений двух и более полей (например, сравнение даты начала и окончания).
Можно ли возвращать статус 400 вместо 422 для ошибок валидации?
Да. Для этого в кастомном обработчике validation_exception_handler достаточно изменить аргумент status_code в объекте JSONResponse на status.HTTP_400_BAD_REQUEST.
Как задокументировать кастомный формат ошибки 422 в OpenAPI (Swagger UI)?
По умолчанию FastAPI описывает стандартную схему HTTPValidationError. Чтобы схема в Swagger отражала ваш кастомный формат, передайте параметр responses в декоратор эндпоинта:
@app.post(
"/orders",
responses={422: {"model": ValidationErrorResponse, "description": "Validation Error"}}
)
async def create_order(payload: OrderCreateRequest):
...
Вывод
Грамотно настроенная валидация защищает сервисный слой приложения от некорректных данных и упрощает интеграцию для клиентских разработчиков.
Использование строгого режима (strict=True) исключает непреднамеренное приведение типов, композиция моделей обеспечивает проверку вложенных коллекций любой глубины, а единый обработчик RequestValidationError с форматированием путей loc превращает технические сообщения Pydantic в понятный и стабильный контракт ошибок. Покрытие этих правил тестами pytest гарантирует надежность API на всех этапах развития проекта.



.svg.webp)




