Практика 3. Практическая работа 3. Pydantic-модели и валидация данных
Цель
- Научиться описывать схемы запроса и ответа с помощью Pydantic (
BaseModel). - Освоить типы и ограничения полей через
Fieldи валидаторы. - Принимать тело
POST-запроса в виде JSON и автоматически его валидировать. - Разделять схемы на Create / Read / Update и понимать, зачем это нужно.
- Видеть, как FastAPI сам возвращает 422 Unprocessable Entity при ошибке валидации.
Теория
Pydantic (версия 2, идёт вместе с FastAPI) — библиотека для валидации данных на основе аннотаций типов Python. Модель — это класс-наследник BaseModel, где каждое поле описано типом, а при необходимости — ограничениями.
Как это работает в FastAPI. Когда эндпойнт принимает параметр типа Pydantic-модели, FastAPI десериализует входящий JSON в объект, валидирует значения по типам и ограничениям, при ошибке сам возвращает HTTP 422 с описанием полей, а при response_model сериализует результат обратно в JSON и документирует схему в /docs.
Field задаёт ограничения и метаданные поля: Field(..., min_length=1) — обязательное поле, Field(default=True) — значение по умолчанию, плюс ge, le, gt, lt, max_length, pattern, description.
Валидаторы проверяют то, что не выразить типом: @field_validator — проверка одного поля, @model_validator — проверка связей между полями.
Разделение схем (важная практика):
| Схема | Назначение | Особенности |
|---|---|---|
| Create | тело POST — что присылает клиент при создании | без id, поля обязательны |
| Read (ответ) | что возвращает сервер | есть id, серверные поля (created_at) |
| Update | тело PATCH — частичное обновление | все поля необязательны (None) |
Клиент не должен присылать id или created_at — их генерирует сервер. Отдельные схемы делают контракт чётким и безопасным.
Связь с лекциями: тело запроса и ответа передаётся в формате JSON (лекция 4), а сами схемы описывают REST-контракт ресурса и работают в паре с
response_modelи кодами состояния (лекция 5).
Задание
Реализуем сервис «Пользователи» без БД (хранение в памяти). Работаем на Windows в PowerShell, как в практике 1.
Шаг 0. Подготовка окружения
mkdir fastapi-userscd fastapi-userspython -m venv .venv.\.venv\Scripts\Activate.ps1python -m pip install --upgrade pippip install fastapi uvicorn "pydantic>=2"Задание 1. Базовая модель и приём тела POST
Создайте main.py. Опишите модель UserCreate и эндпойнт создания.
from datetime import datetime, timezonefrom typing import Optional
from fastapi import FastAPI, HTTPException, statusfrom pydantic import BaseModel, Field, EmailStr, field_validator, model_validator
app = FastAPI(title="Users API (Practice 3)")
class UserCreate(BaseModel): username: str = Field(..., min_length=3, max_length=20, description="Логин, 3..20 символов") email: EmailStr = Field(..., description="Email") age: int = Field(..., ge=14, le=120, description="Возраст 14..120") password: str = Field(..., min_length=8, description="Пароль, минимум 8 символов") is_active: bool = Field(default=True, description="Активен ли аккаунт")
DB: dict[int, dict] = {}_counter = 0
@app.post("/users", status_code=status.HTTP_201_CREATED)def create_user(payload: UserCreate): global _counter _counter += 1 user = payload.model_dump() user["id"] = _counter user["created_at"] = datetime.now(timezone.utc) DB[_counter] = user return userУстановите pip install "pydantic[email]" (для EmailStr). Запустите сервер:
uvicorn main:app --reloadОткройте http://127.0.0.1:8000/docs и создайте пользователя.
Задание 2. Схема ответа (Read) и response_model
Клиент не должен видеть пароль. Заведите отдельную схему ответа без поля password и подключите её через response_model.
class UserRead(BaseModel): id: int username: str email: EmailStr age: int is_active: bool created_at: datetime
@app.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)def create_user(payload: UserCreate): global _counter _counter += 1 user = payload.model_dump() user["id"] = _counter user["created_at"] = datetime.now(timezone.utc) DB[_counter] = user return user # password в ответ не попадёт — его нет в UserReadДобавьте чтение:
@app.get("/users/{user_id}", response_model=UserRead)def get_user(user_id: int): user = DB.get(user_id) if user is None: raise HTTPException(status_code=404, detail="User not found") return user
@app.get("/users", response_model=list[UserRead])def list_users(): return list(DB.values())Убедитесь в /docs, что в ответе POST /users поля password нет.
Задание 3. Проверка автоматической валидации (422)
Не дописывая кода, проверьте поведение при некорректных данных. Через PowerShell:
# Невалидный email и слишком короткий пароль$body = @{ username = "ab"; email = "not-an-email"; age = 10; password = "123" } | ConvertTo-JsonInvoke-RestMethod -Uri http://127.0.0.1:8000/users -Method Post -ContentType "application/json" -Body $bodyВ ответ придёт код 422 и тело с массивом detail, где для каждого поля указаны loc, msg, type. В отчёте опишите, какие именно поля не прошли проверку и почему (username короче 3, email некорректен, age < 14, password короче 8).
Задание 4. Кастомные валидаторы
Добавьте проверки, которые нельзя выразить только типом: логин из допустимых символов и совпадение пароля с подтверждением.
class UserCreate(BaseModel): username: str = Field(..., min_length=3, max_length=20) email: EmailStr age: int = Field(..., ge=14, le=120) password: str = Field(..., min_length=8) password_confirm: str = Field(..., min_length=8) is_active: bool = Field(default=True)
@field_validator("username") @classmethod def username_alnum(cls, v: str) -> str: if not v.isalnum(): raise ValueError("username должен состоять только из букв и цифр") return v
@model_validator(mode="after") def passwords_match(self): if self.password != self.password_confirm: raise ValueError("password и password_confirm не совпадают") return selfОшибка валидатора тоже превращается FastAPI в ответ 422. Проверьте оба случая (символ @ в логине, разные пароли).
Задание 5. Схема Update и частичное обновление (PATCH)
Для частичного обновления заведите схему, где все поля необязательны.
class UserUpdate(BaseModel): username: Optional[str] = Field(None, min_length=3, max_length=20) email: Optional[EmailStr] = None age: Optional[int] = Field(None, ge=14, le=120) is_active: Optional[bool] = None
@app.patch("/users/{user_id}", response_model=UserRead)def update_user(user_id: int, payload: UserUpdate): user = DB.get(user_id) if user is None: raise HTTPException(status_code=404, detail="User not found") changes = payload.model_dump(exclude_unset=True) # только переданные поля user.update(changes) return userexclude_unset=True берёт только реально присланные поля, не затирая остальные значениями по умолчанию. Проверьте: пришлите только {"age": 30} и убедитесь, что прочие поля не изменились.
Задание 6. Самостоятельно
- Добавьте поле
bio: Optional[str] = Field(None, max_length=200)и проверьте отказ при превышении длины. - Реализуйте
PUT /users/{user_id}(полная замена) на базеUserCreate— все поля обязательны. - Сделайте поле
roleограниченным набором значений черезLiteral["user", "admin"]и проверьте ответ 422.
Критерии оценки
| Критерий | Вес |
|---|---|
Схемы Create/Read/Update описаны через BaseModel и Field | 25% |
Приём тела POST и корректный response_model (без пароля в ответе) | 20% |
| Ограничения полей и кастомные валидаторы работают | 20% |
| Продемонстрирован автоматический ответ 422 на невалидных данных | 15% |
PATCH с exclude_unset обновляет только переданные поля | 10% |
| Самостоятельные задания и оформление отчёта | 10% |
Вопросы для самопроверки
- Что такое Pydantic-модель и зачем она нужна в FastAPI?
- Какие три шага выполняет FastAPI с телом запроса, описанным Pydantic-схемой?
- Чем отличаются схемы Create, Read и Update? Почему их разделяют?
- Как сделать поле обязательным, а как задать значение по умолчанию через
Field? - Какой код состояния возвращает FastAPI при ошибке валидации и что содержится в теле ответа?
- В чём разница между
@field_validatorи@model_validator? - Зачем при
PATCHиспользоватьmodel_dump(exclude_unset=True)? - Как
response_modelпомогает не отдавать клиенту лишние поля (например, пароль)? - Чем валидация на уровне типа отличается от кастомного валидатора? Приведите пример каждого.
- В каком формате передаётся тело запроса и ответа и какой заголовок это указывает?