Веб-API на FastAPI и Pydantic
Почему 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
Модель регистрации на 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} должны не пройти валидацию")
from pydantic import BaseModel, Field, field_validator
class UserCreate(BaseModel):
email: str
password: str = Field(min_length=8)
age: int = Field(ge=14, le=120)
tags: list[str] = Field(default_factory=list, max_length=5)
@field_validator("email")
@classmethod
def normalize_email(cls, value: str) -> str:
value = value.strip().lower()
local, at, domain = value.partition("@")
if not local or value.count("@") != 1 or "." not in domain.strip("."):
raise ValueError("некорректный email")
return value
@field_validator("password")
@classmethod
def has_digit(cls, value: str) -> str:
if not any(ch.isdigit() for ch in value):
raise ValueError("пароль должен содержать цифру")
return value
Игрушечный 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)
import inspect
class App:
def __init__(self):
self.routes = []
def get(self, path):
def decorator(func):
self.routes.append((path.strip("/").split("/"), func))
return func
return decorator
def handle(self, path):
parts = path.strip("/").split("/")
for pattern, func in self.routes:
if len(pattern) != len(parts):
continue
raw = {}
for p, value in zip(pattern, parts):
if p.startswith("{") and p.endswith("}"):
raw[p[1:-1]] = value
elif p != value:
break
else:
params = inspect.signature(func).parameters
kwargs = {}
for name, value in raw.items():
annotation = params[name].annotation
convert = str if annotation is inspect.Parameter.empty else annotation
try:
kwargs[name] = convert(value)
except ValueError:
return 422, {"detail": f"invalid {name}"}
return 200, func(**kwargs)
return 404, {"detail": "Not Found"}
Обработчик объявлен как async def, а внутри вызывает requests.get(...) и time.sleep(2). Что произойдёт под нагрузкой?
Откуда 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) |
Войдите, чтобы отмечать пройденные уроки.