Перейти к содержимому

Практика 3. Практическая работа 3. Pydantic-модели и валидация данных

Цель

  1. Научиться описывать схемы запроса и ответа с помощью Pydantic (BaseModel).
  2. Освоить типы и ограничения полей через Field и валидаторы.
  3. Принимать тело POST-запроса в виде JSON и автоматически его валидировать.
  4. Разделять схемы на Create / Read / Update и понимать, зачем это нужно.
  5. Видеть, как 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-users
cd fastapi-users
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install fastapi uvicorn "pydantic>=2"

Задание 1. Базовая модель и приём тела POST

Создайте main.py. Опишите модель UserCreate и эндпойнт создания.

from datetime import datetime, timezone
from typing import Optional
from fastapi import FastAPI, HTTPException, status
from 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-Json
Invoke-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 user

exclude_unset=True берёт только реально присланные поля, не затирая остальные значениями по умолчанию. Проверьте: пришлите только {"age": 30} и убедитесь, что прочие поля не изменились.

Задание 6. Самостоятельно

  1. Добавьте поле bio: Optional[str] = Field(None, max_length=200) и проверьте отказ при превышении длины.
  2. Реализуйте PUT /users/{user_id} (полная замена) на базе UserCreate — все поля обязательны.
  3. Сделайте поле role ограниченным набором значений через Literal["user", "admin"] и проверьте ответ 422.

Критерии оценки

КритерийВес
Схемы Create/Read/Update описаны через BaseModel и Field25%
Приём тела POST и корректный response_model (без пароля в ответе)20%
Ограничения полей и кастомные валидаторы работают20%
Продемонстрирован автоматический ответ 422 на невалидных данных15%
PATCH с exclude_unset обновляет только переданные поля10%
Самостоятельные задания и оформление отчёта10%

Вопросы для самопроверки

  1. Что такое Pydantic-модель и зачем она нужна в FastAPI?
  2. Какие три шага выполняет FastAPI с телом запроса, описанным Pydantic-схемой?
  3. Чем отличаются схемы Create, Read и Update? Почему их разделяют?
  4. Как сделать поле обязательным, а как задать значение по умолчанию через Field?
  5. Какой код состояния возвращает FastAPI при ошибке валидации и что содержится в теле ответа?
  6. В чём разница между @field_validator и @model_validator?
  7. Зачем при PATCH использовать model_dump(exclude_unset=True)?
  8. Как response_model помогает не отдавать клиенту лишние поля (например, пароль)?
  9. Чем валидация на уровне типа отличается от кастомного валидатора? Приведите пример каждого.
  10. В каком формате передаётся тело запроса и ответа и какой заголовок это указывает?