Шаг 04. Pydantic v2 — схемы валидации и сериализации

📁 Серия: Капстоун A ⏱️ ~30 мин 🎯 Сложность: Средняя
#pydantic-v2 #validation #serialization #from_attributes

⚡ Кратко: что делаем на этом шаге

Цель: Написать Pydantic v2 схемы для валидации входа (Create) и сериализации выхода (Out). Подключить model_config = ConfigDict(from_attributes=True) для чтения ORM-объектов.

  • Файлы: app/schemas/question.py, app/schemas/response.py, app/schemas/__init__.py
  • Ключевые классы: QuestionCreate, QuestionOut, ResponseCreate, ResponseOut
  • Результат: входящий JSON валидируется, ORM-объекты сериализуются без ручного .to_dict()

🎯 Цель этапа

Pydantic v2 играет две роли в нашем API: валидация входа (проверяем JSON из запроса) и сериализация выхода (преобразуем ORM-объекты в JSON-совместимые словари). Разделяем эти роли в отдельные классы: XxxCreate для входа, XxxOut для выхода.

💡 from_attributes=True — мост между Pydantic и SQLAlchemy: По умолчанию Pydantic принимает данные из словарей. ConfigDict(from_attributes=True) (бывший orm_mode = True в v1) позволяет Pydantic читать атрибуты Python-объектов — в том числе SQLAlchemy моделей. Тогда QuestionOut.model_validate(question_orm_obj) работает напрямую.

После этого шага у нас будет

  • Схемы для Question: QuestionCreate (валидация) и QuestionOut (сериализация)
  • Схемы для Response: ResponseCreate и ResponseOut
  • Общая схема ErrorOut для единообразных ответов с ошибками

📄 Затрагиваемые файлы

ФайлДействиеОписание
app/schemas/question.pyСоздатьQuestionCreate, QuestionOut
app/schemas/response.pyСоздатьResponseCreate, ResponseOut
app/schemas/common.pyСоздатьОбщие схемы: ErrorOut, MessageOut
app/schemas/__init__.pyИзменитьРеэкспорт всех схем

🔨 Шаги

1. Схемы для Question

📄 app/schemas/question.py
# app/schemas/question.py
from datetime import datetime

from pydantic import BaseModel, ConfigDict, Field


class QuestionCreate(BaseModel):
    """
    Схема входных данных для создания/обновления вопроса.

    Используется в POST /questions/ и PUT /questions/<id>.
    Pydantic валидирует данные из request.get_json() перед записью в БД.
    """
    text: str = Field(
        ...,                          # обязательное поле (no default)
        min_length=5,
        max_length=500,
        description="Текст вопроса",
        examples=["Согласны ли вы с удалённой работой?"],
    )


class QuestionOut(BaseModel):
    """
    Схема выходных данных вопроса — то, что возвращает API.

    model_config с from_attributes=True позволяет создавать из ORM-объектов:
        question_out = QuestionOut.model_validate(question_orm_object)
    """
    model_config = ConfigDict(from_attributes=True)

    id: int
    text: str
    created_at: datetime


class QuestionListOut(BaseModel):
    """Схема для списка вопросов с пагинационной информацией."""
    model_config = ConfigDict(from_attributes=True)

    items: list[QuestionOut]
    total: int

2. Схемы для Response

📄 app/schemas/response.py
# app/schemas/response.py
from datetime import datetime

from pydantic import BaseModel, ConfigDict, Field


class ResponseCreate(BaseModel):
    """
    Схема для добавления голоса.

    Используется в POST /questions/<id>/responses/.
    question_id не нужен — берётся из URL-параметра.
    """
    is_agree: bool = Field(
        ...,
        description="True = согласен, False = не согласен",
    )


class ResponseOut(BaseModel):
    """Схема выходных данных голоса."""
    model_config = ConfigDict(from_attributes=True)

    id: int
    question_id: int
    is_agree: bool
    created_at: datetime

3. Общие схемы ошибок

📄 app/schemas/common.py
# app/schemas/common.py
from pydantic import BaseModel


class ErrorOut(BaseModel):
    """Единообразный формат ошибок API."""
    error: str
    detail: str | None = None


class MessageOut(BaseModel):
    """Простое сообщение об успехе."""
    message: str

4. Экспортируем все схемы

📄 app/schemas/__init__.py
# app/schemas/__init__.py
from app.schemas.question import QuestionCreate, QuestionOut, QuestionListOut
from app.schemas.response import ResponseCreate, ResponseOut
from app.schemas.common import ErrorOut, MessageOut

__all__ = [
    "QuestionCreate",
    "QuestionOut",
    "QuestionListOut",
    "ResponseCreate",
    "ResponseOut",
    "ErrorOut",
    "MessageOut",
]

🧠 Объяснение логики

Почему Create и Out — разные классы

На входе и выходе данные разные. При создании вопроса клиент присылает только text. В ответе мы возвращаем id, text, created_at. Один класс для обоих случаев либо заставит добавлять Optional везде, либо рискует вернуть лишнее (например, хеш пароля в схеме пользователя).

model_validate() vs dict()

Паттерн использования в эндпоинтах (покажем в шаге 05):

📄 Пример (будет в 05-api.html)
# Валидация входа:
data = request.get_json()
schema = QuestionCreate.model_validate(data)  # бросает ValidationError если данные неверны

# Сериализация выхода:
out = QuestionOut.model_validate(question)    # читает атрибуты ORM-объекта
return jsonify(out.model_dump()), 200

Pydantic v1 vs v2

Pydantic v1 (уроки 09–11)Pydantic v2 (капстоун)
class Config: orm_mode = True model_config = ConfigDict(from_attributes=True)
Schema(**data) Schema.model_validate(data)
schema.dict() schema.model_dump()
schema.json() schema.model_dump_json()
⚠️ Частая ошибка — смешивать v1 и v2 синтаксис: В Pydantic v2 старый синтаксис (class Config: orm_mode = True) работает с предупреждением, но будет удалён. Используйте только ConfigDict.

✅ Проверка

1. Тестируем схемы в flask shell

💻 Терминал
flask shell

from app.schemas import QuestionCreate, QuestionOut
from pydantic import ValidationError

# Валидный вопрос
q = QuestionCreate(text="Согласны ли вы с удалённой работой?")
print(q.text)   # Согласны ли вы с удалённой работой?

# Слишком короткий — должна быть ValidationError
try:
    bad = QuestionCreate(text="Hi")
except ValidationError as e:
    print(e)    # 1 validation error for QuestionCreate ...

# Сериализация из словаря
q_out = QuestionOut.model_validate({
    "id": 1, "text": "Тест", "created_at": "2025-01-01T00:00:00"
})
print(q_out.model_dump())

exit()

2. Ожидаемый результат

Успех: QuestionCreate проходит валидацию для длинных строк и бросает ошибку для коротких. QuestionOut.model_dump() возвращает словарь с нужными полями.

➡️ Что дальше

На следующем шаге создаём Blueprints и CRUD-эндпоинты. Схемы из этого шага будут использоваться в каждом эндпоинте: QuestionCreate для валидации тела запроса, QuestionOut для формирования ответа.

  • Готово: схемы QuestionCreate/Out, ResponseCreate/Out, ErrorOut — все протестированы
  • Далее (шаг 05): blueprints, CRUD-эндпоинты, фильтрация через request.args