Веб-API на FastAPI и Pydantic

⏱ 55 мин · 🧩 заданий: 4

Почему FastAPI

FastAPI — самый популярный фреймворк для HTTP API на Python. Его идея: вы описываете параметры и данные аннотациями типов, а фреймворк по ним сам:

  • разбирает и валидирует запрос (через Pydantic) и возвращает понятную ошибку 422;
  • сериализует ответ в JSON;
  • генерирует документацию OpenAPI со страницей /docs, где можно потыкать API;
  • даёт автодополнение и проверку типов в редакторе.

Он асинхронный (работает на ASGI-сервере uvicorn), но поддерживает и обычные функции. Сначала разберёмся с Pydantic — он работает прямо в браузере.

Pydantic: модели и валидация

from datetime import date
from pydantic import BaseModel, Field, ValidationError

class Address(BaseModel):
    city: str
    street: str | None = None

class User(BaseModel):
    id: int
    name: str = Field(min_length=2, max_length=50)
    birthday: date | None = None
    tags: list[str] = []
    address: Address                       # вложенная модель

data = {"id": "42", "name": "Анна", "birthday": "1995-04-12", "address": {"city": "Казань"}}
user = User(**data)                        # или User.model_validate(data)
print(user)
print(type(user.id), type(user.birthday))  # строки приведены к int и date
print(user.address.city)

try:
    User(id="сорок два", name="А", address={})
except ValidationError as e:
    print(e.error_count(), "ошибки")
    for err in e.errors():
        print(err["loc"], err["msg"])

Pydantic по умолчанию работает в «мягком» режиме: "42" превращается в 42, "1995-04-12" — в date. Строгий режим (model_config = ConfigDict(strict=True) или Field(strict=True)) запрещает приведение.

Ограничения и валидаторы

from decimal import Decimal
from pydantic import BaseModel, Field, field_validator, model_validator, ValidationError

class OrderItem(BaseModel):
    product_id: int = Field(gt=0)
    quantity: int = Field(ge=1, le=100, default=1)
    price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)

class Order(BaseModel):
    items: list[OrderItem] = Field(min_length=1)
    promo_code: str | None = None
    delivery: str = Field(pattern=r"^(courier|pickup)$")

    @field_validator("promo_code")
    @classmethod
    def upper_promo(cls, v):
        return v.strip().upper() if v else v

    @model_validator(mode="after")
    def check_total(self):
        if self.delivery == "courier" and self.total < 500:
            raise ValueError("доставка курьером — от 500 ₽")
        return self

    @property
    def total(self) -> Decimal:
        return sum((i.price * i.quantity for i in self.items), Decimal(0))

order = Order(items=[{"product_id": 1, "quantity": 2, "price": "349.90"}], promo_code=" spring ", delivery="courier")
print(order.total, order.promo_code)

for bad in ({"items": [], "delivery": "pickup"},
            {"items": [{"product_id": 1, "price": "100"}], "delivery": "courier"},
            {"items": [{"product_id": 0, "price": "1.999"}], "delivery": "drone"}):
    try:
        Order(**bad)
    except ValidationError as e:
        print([f"{'.'.join(map(str, err['loc'])) or 'модель'}: {err['msg']}" for err in e.errors()])

Сериализация

from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict

class UserOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)    # можно строить из объектов ORM

    id: int
    email: str
    created_at: datetime
    full_name: str = Field(serialization_alias="fullName")

class OrmUser:                                         # как будто модель SQLAlchemy
    def __init__(self):
        self.id, self.email, self.full_name = 1, "anna@example.com", "Анна Петрова"
        self.created_at = datetime(2026, 3, 1, 12, 30)
        self.password_hash = "секрет — не должен утечь"

user = UserOut.model_validate(OrmUser())
print(user.model_dump())
print(user.model_dump_json(by_alias=True))
print(user.model_dump(include={"id", "email"}))
print(UserOut.model_json_schema()["required"])

Отдельная модель для ответа — главный способ не отдать наружу лишнее (хеш пароля, внутренние поля). Обычно у сущности несколько схем: UserCreate (вход, с паролем), UserUpdate (все поля необязательные), UserOut (выход, без пароля).

Первое приложение

# main.py
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field

app = FastAPI(title="Todo API", version="1.0")

class TaskIn(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    done: bool = False

class TaskOut(TaskIn):
    id: int

tasks: dict[int, TaskOut] = {}

@app.get("/tasks", response_model=list[TaskOut])
def list_tasks(done: bool | None = None, limit: int = 20, offset: int = 0):     # query-параметры
    items = [t for t in tasks.values() if done is None or t.done == done]
    return items[offset: offset + limit]

@app.get("/tasks/{task_id}", response_model=TaskOut)
def get_task(task_id: int):                                                      # параметр пути
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Задача не найдена")
    return tasks[task_id]

@app.post("/tasks", response_model=TaskOut, status_code=status.HTTP_201_CREATED)
def create_task(task: TaskIn):                                                   # тело JSON
    new = TaskOut(id=len(tasks) + 1, **task.model_dump())
    tasks[new.id] = new
    return new

@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    if tasks.pop(task_id, None) is None:
        raise HTTPException(404, "Задача не найдена")
pip install "fastapi[standard]"
fastapi dev main.py                  # или: uvicorn main:app --reload
# http://127.0.0.1:8000/docs — интерактивная документация Swagger UI
curl -X POST localhost:8000/tasks -H "Content-Type: application/json" -d '{"title": "Выучить FastAPI"}'

Правило определения источника: имя есть в пути — параметр пути; простой тип — query-параметр; модель Pydantic — тело. Неверные данные → автоматический ответ 422 со списком ошибок.

Как это работает: аннотации + inspect

FastAPI читает сигнатуру обработчика и по аннотациям решает, откуда взять и как привести каждое значение. Упрощённая версия этого механизма:

import inspect, json

def call_handler(func, path_params, query, body):
    kwargs = {}
    for name, param in inspect.signature(func).parameters.items():
        ann = param.annotation
        if name in path_params:
            kwargs[name] = ann(path_params[name])
        elif hasattr(ann, "model_validate"):            # модель Pydantic → тело запроса
            kwargs[name] = ann.model_validate(json.loads(body))
        elif name in query:
            kwargs[name] = ann(query[name])
        elif param.default is not inspect.Parameter.empty:
            kwargs[name] = param.default
        else:
            raise ValueError(f"422: не передан {name}")
    return func(**kwargs)

from pydantic import BaseModel

class Item(BaseModel):
    title: str
    price: float

def update_item(item_id: int, item: Item, notify: bool = False):
    return {"id": item_id, "item": item.model_dump(), "notify": notify}

print(inspect.signature(update_item))
print(call_handler(update_item, {"item_id": "7"}, {}, '{"title": "Кружка", "price": "499"}'))

В задании к уроку вы напишете похожий маршрутизатор сами.

Внедрение зависимостей: Depends

Зависимость — функция, результат которой FastAPI передаст в обработчик. Через зависимости делают сессию базы данных, текущего пользователя, пагинацию, проверку прав. Зависимости могут зависеть от других и использовать yield для очистки ресурсов.

from typing import Annotated
from fastapi import Depends, Header, HTTPException
from sqlalchemy.orm import Session

def get_session():
    with SessionLocal() as session:
        yield session                                  # после ответа сессия закроется

SessionDep = Annotated[Session, Depends(get_session)]

def get_current_user(session: SessionDep, authorization: str = Header()):
    token = authorization.removeprefix("Bearer ")
    user = session.scalars(select(User).where(User.token == token)).one_or_none()
    if user is None:
        raise HTTPException(401, "Неверный токен", headers={"WWW-Authenticate": "Bearer"})
    return user

CurrentUser = Annotated[User, Depends(get_current_user)]

class Pagination(BaseModel):
    limit: int = Field(20, le=100)
    offset: int = 0

@app.get("/me/orders", response_model=list[OrderOut])
def my_orders(user: CurrentUser, session: SessionDep, page: Annotated[Pagination, Depends()]):
    stmt = select(Order).where(Order.user_id == user.id).limit(page.limit).offset(page.offset)
    return session.scalars(stmt).all()

Главный плюс — тестируемость: в тестах любую зависимость подменяют через app.dependency_overrides[get_session] = fake_session.

Структура проекта

app/
├── main.py              # создание FastAPI(), подключение роутеров, middleware
├── config.py            # настройки (pydantic-settings)
├── db.py                # engine, SessionLocal, get_session
├── models/              # модели SQLAlchemy
├── schemas/             # модели Pydantic: UserCreate, UserOut…
├── api/
│   ├── deps.py          # общие зависимости: текущий пользователь, пагинация
│   └── routes/
│       ├── users.py     # router = APIRouter(prefix="/users", tags=["users"])
│       └── orders.py
├── services/            # бизнес-логика, не зависит от HTTP
└── tests/
# app/main.py
app = FastAPI()
app.include_router(users.router, prefix="/api/v1")
app.include_router(orders.router, prefix="/api/v1")

Держите обработчики тонкими: разобрать запрос → вызвать сервис → вернуть ответ. Бизнес-логику в services/ легко тестировать без HTTP.

async def или def

  • async def — когда внутри только асинхронные вызовы (await session.execute(...) с AsyncSession, httpx.AsyncClient, asyncio.sleep). Работает в цикле событий, держит тысячи одновременных соединений.
  • def — когда используете синхронные библиотеки (requests, обычная SQLAlchemy Session). FastAPI выполнит их в пуле потоков.
  • Ошибка — блокирующий вызов внутри async def: он останавливает весь сервер.

Ещё возможности

from fastapi import BackgroundTasks, Request
from fastapi.middleware.cors import CORSMiddleware
import time

@app.post("/signup")
def signup(user: UserCreate, background: BackgroundTasks):
    created = create_user(user)
    background.add_task(send_welcome_email, created.email)   # после ответа; для тяжёлого — Celery/RQ/arq
    return created

@app.middleware("http")
async def timing(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.4f}"
    return response

app.add_middleware(CORSMiddleware, allow_origins=["https://my-frontend.example"], allow_methods=["*"])

@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError):
    return JSONResponse(status_code=409, content={"error": {"code": exc.code, "message": str(exc)}})

Тесты

from fastapi.testclient import TestClient
from app.main import app, get_session

def fake_session():
    yield TestingSession()

app.dependency_overrides[get_session] = fake_session
client = TestClient(app)

def test_create_and_get_task():
    r = client.post("/tasks", json={"title": "Тест"})
    assert r.status_code == 201
    task_id = r.json()["id"]
    assert client.get(f"/tasks/{task_id}").json()["title"] == "Тест"

def test_validation_error():
    r = client.post("/tasks", json={"title": ""})
    assert r.status_code == 422
    assert r.json()["detail"][0]["loc"] == ["body", "title"]

FastAPI, Django, Flask

FastAPI Django (+ DRF) Flask
Назначение API, микросервисы полноценные сайты и API, «батарейки в комплекте» минималистичный, всё подключается отдельно
ORM любая (обычно SQLAlchemy) встроенная любая
Админка, аутентификация нет из коробки есть нет
Async изначально частично частично
Валидация Pydantic формы и сериализаторы DRF сторонние библиотеки

Итог

  • Pydantic: модели с типами, мягкое приведение, Field-ограничения, field_validator/model_validator, model_dump.
  • FastAPI берёт параметры из пути, query и тела по аннотациям; ошибки валидации → 422.
  • response_model защищает от утечки полей; HTTPException — для ошибок.
  • Depends — сессия БД, текущий пользователь, пагинация; подменяются в тестах.
  • async def только с асинхронными библиотеками; иначе обычный def.

Практика решено 0 из 4

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Модель регистрации на Pydantic. Опишите модель UserCreate:

  • email — строка, приводится к нижнему регистру и обрезается по краям; должна содержать ровно один @ и точку после него (иначе ошибка валидации);
  • password — строка от 8 символов, обязательно хотя бы одна цифра;
  • age — целое от 14 до 120 включительно;
  • tags — список строк, по умолчанию пустой, не больше 5 элементов.

Используйте Field(...) для ограничений и @field_validator для своих проверок.

Какие тесты проверяют код (2)
u = UserCreate(email="  Anna@Example.COM ", password="secret123", age="30")
assert (u.email, u.age, u.tags) == ("anna@example.com", 30, [])
assert UserCreate(email="a@b.ru", password="12345678", age=14, tags=["x"] * 5).tags == ["x"] * 5

from pydantic import ValidationError
good = dict(email="a@b.ru", password="secret123", age=30)
for bad in ({"email": "ab.ru"}, {"email": "a@@b.ru"}, {"email": "a@localhost"}, {"password": "short1"},
            {"password": "longpassword"}, {"age": 13}, {"age": 121}, {"age": "много"}, {"tags": ["t"] * 6}):
    try:
        UserCreate(**{**good, **bad})
    except ValidationError:
        pass
    else:
        raise AssertionError(f"Данные {bad} должны не пройти валидацию")

Задание 2 Программа на Python

Игрушечный FastAPI. Напишите класс App с декоратором @app.get(path) и методом handle(path):

  • шаблон пути может содержать параметры: /items/{item_id}; путь совпадает, если совпадает число сегментов и все сегменты без фигурных скобок;
  • значения параметров приводятся к типу из аннотации аргумента функции (int, float, str; без аннотации — str); получить аннотации можно через inspect.signature;
  • handle возвращает пару (статус, результат): (200, результат_функции); если маршрут не найден — (404, {"detail": "Not Found"}); если привести тип не удалось — (422, {"detail": "invalid <имя параметра>"}).
Какие тесты проверяют код (2)
app = App()

@app.get("/")
def root():
    return {"message": "hello"}

@app.get("/items/{item_id}")
def get_item(item_id: int):
    return {"item_id": item_id, "type": type(item_id).__name__}

@app.get("/users/{name}/orders/{order_id}")
def get_order(name, order_id: int):
    return [name, order_id]

assert app.handle("/") == (200, {"message": "hello"})
assert app.handle("/items/42") == (200, {"item_id": 42, "type": "int"})
assert app.handle("/users/anna/orders/7") == (200, ["anna", 7])

assert app.handle("/items/abc") == (422, {"detail": "invalid item_id"})
assert app.handle("/items") == (404, {"detail": "Not Found"}) and app.handle("/nope/1") == (404, {"detail": "Not Found"})
assert app.handle("/users/anna/carts/7") == (404, {"detail": "Not Found"})

@app.get("/price/{value}")
def price(value: float):
    return value * 2
assert app.handle("/price/2.5") == (200, 5.0)

Задание 3 Один вариант ответа

Обработчик объявлен как async def, а внутри вызывает requests.get(...) и time.sleep(2). Что произойдёт под нагрузкой?

Задание 4 Заполнить таблицу

Откуда FastAPI возьмёт значение параметра обработчика def f(item_id: int, q: str | None = None, item: Item = ...) для маршрута /items/{item_id}?

путьquery-строкатело JSON
item_id: int
q: str | None = None
item: Item (модель Pydantic)
Отметьте ячейку — «+», пустая — «−».

Войдите, чтобы отмечать пройденные уроки.