FastAPI — Python-фреймворк для HTTP API, в котором типы параметров и модели Pydantic одновременно служат валидацией входа, фильтром ответа и описанием API: по ним строится схема OpenAPI и интерактивная документация на /docs. Маршрут — обычная функция с декоратором @app.get или @app.post, общая логика (доступ к хранилищу, проверка ключа) подключается через Depends, а тесты пишут обычными функциями pytest с TestClient. В уроке собран API заявок с семью тестами, проверенный на FastAPI 0.143 и Pydantic 2.14.
После материала вы сможете
- создать виртуальное окружение, установить
fastapi[standard]и запустить API командойfastapi dev - описать модели Pydantic с ограничениями
Fieldи объяснить, откуда берётся ответ 422 и как читать егоlocиtype - сделать CRUD-эндпоинты с кодами 201, 204, 401 и 404 и подключить проверку ключа через
Depends - покрыть API тестами
TestClientи pytest с подменой зависимости черезapp.dependency_overrides
Что знать заранее
Проверено на
Python 3.12FastAPI 0.143Pydantic 2.14Starlette 1.7pytest 9.1httpx2 2.13
Сверено с документацией: 11 октября 2026 г.
Содержание
Что такое FastAPI и что мы соберём
Как устроен бэкенд в целом — сервер, база, API — мы разбирали в материале «Что такое бэкенд», а формат запросов и ответов — в уроке про HTTP и JSON. Здесь — практика на Python. FastAPI построен на двух библиотеках: Starlette отвечает за веб-часть (маршрутизация, запросы, ответы), Pydantic — за данные. Вы описываете, какие данные принимает и отдаёт эндпоинт — адрес плюс HTTP-метод, — обычными аннотациями типов Python, а фреймворк сам проверяет вход, сериализует ответ в JSON и документирует API.
Учебный проект — API заявок с сайта. Форма на сайте отправляет заявку без авторизации, а менеджер через закрытые эндпоинты смотрит список, меняет статус и удаляет спам. Данные хранятся в памяти процесса: так урок не зависит от базы данных, а тесты получают чистое хранилище на каждый запуск.
POST /leads
- Кто вызывает
- форма на сайте
- Успех
- 201 Created
- Ошибки
- 422 — неверные данные
GET /leads?status=&limit=
- Кто вызывает
- менеджер, нужен ключ
- Успех
- 200 OK
- Ошибки
- 401, 422
GET /leads/{lead_id}
- Кто вызывает
- менеджер
- Успех
- 200 OK
- Ошибки
- 401, 404
PATCH /leads/{lead_id}
- Кто вызывает
- менеджер
- Успех
- 200 OK
- Ошибки
- 401, 404, 422
DELETE /leads/{lead_id}
- Кто вызывает
- менеджер
- Успех
- 204 No Content
- Ошибки
- 401, 404
GET /health
- Кто вызывает
- мониторинг
- Успех
- 200 OK
- Ошибки
- —
Окружение: venv и зависимости
Зависимости проекта ставят в виртуальное окружение — изолированную копию интерпретатора со своими пакетами. Документация Python описывает его как одноразовое: его не коммитят в Git и при необходимости удаляют и создают заново. Официальное руководство FastAPI сейчас показывает установку через менеджер uv, а для pip предлагает создать и активировать окружение и выполнить pip install "fastapi[standard]" — этот путь мы и используем.
mkdir leads-api
cd leads-api
python -m venv .venv
source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1
python -m pip install "fastapi[standard]" pytest httpx2
mkdir app tests # и пустой файл app/__init__.pyfastapi[standard] нужны, чтобы квадратные скобки не интерпретировала оболочкаfastapi[standard]— FastAPI с набором дополнительных пакетов: серверuvicorn, командаfastapi,email-validatorдля Pydantic,jinja2,python-multipartиhttpx.pytest— тестовый раннер. Тесты в нём — обычные функции сassert.httpx2— HTTP-клиент, на котором построенTestClientв Starlette 1.7. Документация Starlette называет обычныйhttpxподдерживаемым, но устаревшим вариантом. Безhttpx2тесты проходят, но pytest печатает предупреждениеStarletteDeprecationWarning: мы проверили оба варианта.
Чтобы pytest находил пакет app из папки tests, добавьте в корень pyproject.toml. Таблицу [tool.pytest] с настоящими TOML-типами pytest поддерживает с версии 9.0. В более старых версиях используется [tool.pytest.ini_options].
[tool.pytest]
pythonpath = ["."]
testpaths = ["tests"]Итоговая структура: app/__init__.py (пустой), app/main.py, tests/test_leads.py, pyproject.toml. Папку .venv добавьте в .gitignore.
Модели Pydantic: что API принимает и отдаёт
Модель Pydantic — класс-наследник BaseModel с аннотированными полями. Pydantic гарантирует, что поля созданного объекта соответствуют объявленным типам, а при неверных данных выбрасывает ValidationError. FastAPI перехватывает эту ошибку для тела запроса и сам отвечает клиенту кодом 422 со списком проблем. Ограничения задаются через Field внутри Annotated: длина строки, регулярное выражение, числовые границы.
import os
import secrets
from typing import Annotated, Literal
from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
from pydantic import BaseModel, Field
app = FastAPI(title="Leads API", version="1.0.0")
Service = Literal["website", "mobile-app", "crm"]
LeadStatus = Literal["new", "in_progress", "done"]
class LeadIn(BaseModel):
name: Annotated[str, Field(min_length=2, max_length=80)]
phone: Annotated[str, Field(pattern=r"^\+?\d{10,15}$", examples=["+77011234567"])]
service: Service
comment: Annotated[str | None, Field(max_length=500)] = None
class Lead(LeadIn):
id: int
status: LeadStatus = "new"
class LeadStatusUpdate(BaseModel):
status: LeadStatusField(min_length=2, max_length=80)
- Что проверяется
- длина строки
- Ошибка в ответе 422
string_too_short/string_too_long
Field(pattern=r"^\+?\d{10,15}$")
- Что проверяется
- строка совпадает с регулярным выражением
- Ошибка в ответе 422
string_pattern_mismatch
Literal["website", "mobile-app", "crm"]
- Что проверяется
- значение из фиксированного списка
- Ошибка в ответе 422
literal_error
str | None = None
- Что проверяется
- поле необязательное, по умолчанию
None - Ошибка в ответе 422
- —
Query(ge=1, le=100)
- Что проверяется
- число в диапазоне 1–100 (для параметра запроса)
- Ошибка в ответе 422
greater_than_equal/less_than_equal
Модели разделены по назначению. LeadIn — то, что присылает форма: клиент не может передать id или status. Lead наследует все поля LeadIn и добавляет поля сервера. LeadStatusUpdate — тело PATCH с единственным полем. Такое разделение — официальный приём FastAPI: в разделе о модели ответа документация показывает пару UserIn с паролем и UserOut без него. Тип возврата функции работает как фильтр: FastAPI проверяет ответ, сериализует его через Pydantic и отбрасывает поля, которых нет в модели. Лишние поля во входящем JSON Pydantic по умолчанию игнорирует: мы отправили в POST /leads поля "id": 99 и "status": "done" — заявка всё равно создалась с id 1 и статусом new.
Хранилище и зависимости: Depends
Зависимость в FastAPI — функция, которую фреймворк вызывает перед обработчиком и результат которой передаёт ему в параметр. Документация называет это внедрением зависимостей (dependency injection): код маршрута объявляет, что ему нужно для работы, а FastAPI это предоставляет. Зависимость принимает те же параметры, что и обработчик: заголовки, параметры запроса и даже другие зависимости.
class LeadStore:
"""In-memory storage for the lesson and tests. A real project uses a database."""
def __init__(self) -> None:
self._items: dict[int, Lead] = {}
self._next_id = 1
def add(self, data: LeadIn) -> Lead:
lead = Lead(id=self._next_id, **data.model_dump())
self._items[lead.id] = lead
self._next_id += 1
return lead
def list(self, status: LeadStatus | None, limit: int) -> list[Lead]:
leads = [lead for lead in self._items.values() if status is None or lead.status == status]
return leads[:limit]
def get(self, lead_id: int) -> Lead | None:
return self._items.get(lead_id)
def save(self, lead: Lead) -> None:
self._items[lead.id] = lead
def delete(self, lead_id: int) -> bool:
return self._items.pop(lead_id, None) is not None
store = LeadStore()
def get_store() -> LeadStore:
return store
def require_api_key(x_api_key: Annotated[str | None, Header()] = None) -> None:
expected = os.environ.get("LEADS_API_KEY")
if not expected or x_api_key is None or not secrets.compare_digest(x_api_key, expected):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key")
StoreDep = Annotated[LeadStore, Depends(get_store)]
admin_only = [Depends(require_api_key)]
def find_lead(lead_id: int, leads: LeadStore) -> Lead:
lead = leads.get(lead_id)
if lead is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lead not found")
return leadget_storeотдаёт хранилищеМаршруты получают его через параметр типа
StoreDepи не обращаются к глобальной переменнойstoreнапрямую. В тестах эту зависимость подменяют на чистое хранилище — без правки кода приложения.StoreDep— псевдоним типаAnnotated[LeadStore, Depends(get_store)]записан один раз и переиспользуется. Документация FastAPI рекомендует форму сAnnotated(с версии 0.95) и показывает именно такой псевдоним.require_api_keyпроверяет заголовокПараметр
x_api_keyсHeader()FastAPI читает из заголовкаx-api-key: подчёркивания автоматически превращаются в дефисы, регистр заголовков не важен.secrets.compare_digestсравнивает строки за время, не зависящее от совпадающего префикса.admin_onlyподключается в декораторСписок
dependencies=[Depends(require_api_key)]в декораторе выполняет проверку, но не передаёт её результат в функцию. Это официальный способ для зависимостей, которые только разрешают или запрещают запрос.
find_lead — обычная вспомогательная функция, не зависимость: она выбрасывает HTTPException с кодом 404, если заявки нет. HTTPException — исключение Python с данными для HTTP-ответа: его выбрасывают через raise, а не возвращают, и FastAPI превращает его в ответ вида {"detail": "Lead not found"}.
Маршруты, коды ответов и ошибки
Маршрут — функция с декоратором, где указаны метод и путь. Параметры функции FastAPI раскладывает сам: lead_id: int совпадает с {lead_id} в пути — это параметр пути; модель Pydantic — тело запроса; простые типы, которых нет в пути, — параметры запроса (query string, часть URL после ?). Код успешного ответа задают в декораторе через status_code; константы из fastapi.status хранят те же числа, но читаются лучше.
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/leads", status_code=status.HTTP_201_CREATED)
def create_lead(data: LeadIn, leads: StoreDep) -> Lead:
return leads.add(data)
@app.get("/leads", dependencies=admin_only)
def list_leads(
leads: StoreDep,
status_filter: Annotated[LeadStatus | None, Query(alias="status")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
) -> list[Lead]:
return leads.list(status_filter, limit)
@app.get("/leads/{lead_id}", dependencies=admin_only)
def read_lead(lead_id: int, leads: StoreDep) -> Lead:
return find_lead(lead_id, leads)
@app.patch("/leads/{lead_id}", dependencies=admin_only)
def update_lead_status(lead_id: int, update: LeadStatusUpdate, leads: StoreDep) -> Lead:
lead = find_lead(lead_id, leads)
updated = lead.model_copy(update=update.model_dump())
leads.save(updated)
return updated
@app.delete("/leads/{lead_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=admin_only)
def delete_lead(lead_id: int, leads: StoreDep) -> None:
if not leads.delete(lead_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lead not found")status_filterсQuery(alias="status"): в URL параметр называетсяstatus, но в Python так уже называется импортированный модульfastapi.status, поэтому имя переменной другое.PATCHберёт найденную заявку и черезmodel_copy(update=...)создаёт копию с новым статусом — приём из раздела документации о частичных обновлениях.DELETEотвечает 204 No Content. У такого ответа не должно быть тела: FastAPI это учитывает и в документации указывает, что тела нет.- Функции объявлены через обычный
def. FastAPI выполняет такие обработчики в пуле потоков, чтобы они не блокировали сервер.async defнужен, когда внутри естьawait— например, асинхронный драйвер базы. Если сомневаетесь, документация советует обычныйdef.
200 OK
- Когда в нашем API
- успешный
GETиPATCH - Кто формирует
- FastAPI по умолчанию
201 Created
- Когда в нашем API
- заявка создана
- Кто формирует
status_codeв декораторе
204 No Content
- Когда в нашем API
- заявка удалена
- Кто формирует
status_codeв декораторе
401 Unauthorized
- Когда в нашем API
- нет ключа или он неверный
- Кто формирует
HTTPExceptionв зависимости
404 Not Found
- Когда в нашем API
- заявки с таким
idнет - Кто формирует
HTTPExceptionвfind_lead
422 Unprocessable Content
- Когда в нашем API
- тело или параметры не прошли валидацию
- Кто формирует
- FastAPI автоматически
Запуск и автодокументация /docs
Команда fastapi dev запускает сервер разработки с автоперезагрузкой при сохранении файлов. Перед запуском задайте ключ администратора в переменной окружения — значение ниже только для локальной проверки.
$ export LEADS_API_KEY=dev-secret-change-me # PowerShell: $env:LEADS_API_KEY = "dev-secret-change-me"
$ fastapi dev app/main.py
⚡️ Starting FastAPI in development mode
🐍 Using import string: app.main:app
🌐 Server started at http://127.0.0.1:8000
Documentation at http://127.0.0.1:8000/docs
Logs:
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [74464] using WatchFiles
INFO: Started server process [42936]
INFO: Waiting for application startup.
INFO: Application startup complete.Проверим API из второго терминала. В выводе ниже — реальные ответы сервера: успешное создание, три ошибки валидации в одном ответе, отказ без ключа, список с ключом и 404.
$ curl -i -X POST http://127.0.0.1:8000/leads -H "Content-Type: application/json" -d '{"name": "Aigerim", "phone": "+77011234567", "service": "website"}'
HTTP/1.1 201 Created
date: Sun, 11 Oct 2026 23:13:28 GMT
server: uvicorn
content-length: 98
content-type: application/json
{"name":"Aigerim","phone":"+77011234567","service":"website","comment":null,"id":1,"status":"new"}
$ curl -s -X POST http://127.0.0.1:8000/leads -H "Content-Type: application/json" -d '{"name": "A", "phone": "call me", "service": "seo"}' | python -m json.tool
{
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"name"
],
"msg": "String should have at least 2 characters",
"input": "A",
"ctx": {
"min_length": 2
}
},
{
"type": "string_pattern_mismatch",
"loc": [
"body",
"phone"
],
"msg": "String should match pattern '^\\+?\\d{10,15}$'",
"input": "call me",
"ctx": {
"pattern": "^\\+?\\d{10,15}$"
}
},
{
"type": "literal_error",
"loc": [
"body",
"service"
],
"msg": "Input should be 'website', 'mobile-app' or 'crm'",
"input": "seo",
"ctx": {
"expected": "'website', 'mobile-app' or 'crm'"
}
}
]
}
$ curl -s http://127.0.0.1:8000/leads
{"detail":"Invalid or missing API key"}
$ curl -s http://127.0.0.1:8000/leads -H "X-API-Key: dev-secret-change-me"
[{"name":"Aigerim","phone":"+77011234567","service":"website","comment":null,"id":1,"status":"new"}]
$ curl -s http://127.0.0.1:8000/leads/42 -H "X-API-Key: dev-secret-change-me"
{"detail":"Lead not found"}Теперь откройте http://127.0.0.1:8000/docs. Это Swagger UI, построенный по схеме http://127.0.0.1:8000/openapi.json (OpenAPI 3.1). Альтернативный вид той же схемы — ReDoc на /redoc. В нашей схеме три пути и пять моделей, включая служебные HTTPValidationError и ValidationError. Параметры status, limit и заголовок x-api-key у GET /leads попали в документацию сами — из аннотаций.
- В
/docsраскройтеPOST /leads, нажмите «Try it out», отправьте заявку — ответ 201 сid - Отправьте телефон с буквами — ответ 422 и
loc: ["body", "phone"] - Вызовите
GET /leadsбез заголовка — 401; сx-api-keyиз переменной окружения — список - Раскройте схему
Leadвнизу страницы: ограничения длины и шаблон телефона перенесены изField - Откройте
/openapi.json— это машиночитаемый контракт, по которому фронтенд или партнёр могут сгенерировать клиент
Тесты: TestClient и pytest
TestClient вызывает приложение напрямую, без запущенного сервера и сети. Документация FastAPI подчёркивает: тестовые функции — обычные def, а вызовы клиента — без await, поэтому pytest работает без дополнительных плагинов. Фикстура pytest — функция, которая готовит окружение для теста. Наша фикстура client делает три вещи: задаёт ключ через monkeypatch.setenv, подменяет зависимость get_store на новое хранилище через app.dependency_overrides и после теста сбрасывает подмены.
import pytest
from fastapi.testclient import TestClient
from app.main import LeadStore, app, get_store
API_KEY = "test-key"
ADMIN = {"X-API-Key": API_KEY}
VALID_LEAD = {"name": "Aigerim", "phone": "+77011234567", "service": "website"}
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("LEADS_API_KEY", API_KEY)
fresh_store = LeadStore()
app.dependency_overrides[get_store] = lambda: fresh_store
yield TestClient(app)
app.dependency_overrides = {}
def test_create_lead_returns_201_with_id(client):
response = client.post("/leads", json=VALID_LEAD)
assert response.status_code == 201
assert response.json() == {**VALID_LEAD, "comment": None, "id": 1, "status": "new"}
def test_invalid_phone_returns_422(client):
response = client.post("/leads", json={**VALID_LEAD, "phone": "call me"})
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "phone"]
assert error["type"] == "string_pattern_mismatch"
def test_admin_routes_require_api_key(client):
assert client.get("/leads").status_code == 401
assert client.get("/leads", headers={"X-API-Key": "wrong"}).status_code == 401
assert client.get("/leads", headers=ADMIN).status_code == 200
def test_missing_lead_returns_404(client):
response = client.get("/leads/999", headers=ADMIN)
assert response.status_code == 404
assert response.json() == {"detail": "Lead not found"}
def test_update_status_and_filter(client):
client.post("/leads", json=VALID_LEAD)
client.post("/leads", json={**VALID_LEAD, "service": "crm"})
response = client.patch("/leads/2", json={"status": "in_progress"}, headers=ADMIN)
assert response.status_code == 200
assert response.json()["status"] == "in_progress"
in_progress = client.get("/leads", params={"status": "in_progress"}, headers=ADMIN).json()
assert [lead["id"] for lead in in_progress] == [2]
def test_limit_is_validated(client):
response = client.get("/leads", params={"limit": 0}, headers=ADMIN)
assert response.status_code == 422
def test_delete_returns_204_and_removes_lead(client):
client.post("/leads", json=VALID_LEAD)
response = client.delete("/leads/1", headers=ADMIN)
assert response.status_code == 204
assert response.content == b""
assert client.get("/leads/1", headers=ADMIN).status_code == 404$ pytest -v
============================= test session starts =============================
platform win32 -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0 -- …\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: …\leads-api
configfile: pyproject.toml
testpaths: tests
plugins: anyio-4.15.1
collecting ... collected 7 items
tests/test_leads.py::test_create_lead_returns_201_with_id PASSED [ 14%]
tests/test_leads.py::test_invalid_phone_returns_422 PASSED [ 28%]
tests/test_leads.py::test_admin_routes_require_api_key PASSED [ 42%]
tests/test_leads.py::test_missing_lead_returns_404 PASSED [ 57%]
tests/test_leads.py::test_update_status_and_filter PASSED [ 71%]
tests/test_leads.py::test_limit_is_validated PASSED [ 85%]
tests/test_leads.py::test_delete_returns_204_and_removes_lead PASSED [100%]
============================== 7 passed in 0.43s ==============================app.dependency_overrides— обычный словарь «исходная зависимость → замена». Документация советует сбрасывать его присваиванием пустого словаря — иначе подмена из одного теста проживёт до следующего.- Тест
test_invalid_phone_returns_422проверяет не только код, но иlocиtypeошибки. Если кто-то ослабит шаблон телефона, тест это заметит. - Тест доступа проверяет три случая: без ключа, с неверным ключом и с верным. Проверка только успешного пути не ловит самую опасную ошибку — открытый закрытый эндпоинт.
Частые ошибки и упражнения
ModuleNotFoundError: No module named 'app' в pytest
- Причина
- корень проекта не в пути импорта
- Исправление
pythonpath = ["."]вpyproject.tomlили запускpython -m pytestиз корня
fastapi: command not found
- Причина
- окружение не активировано или ставили без
[standard] - Исправление
- активируйте
.venv, установите"fastapi[standard]"
StarletteDeprecationWarning про httpx в тестах
- Причина
TestClientработает через старыйhttpx- Исправление
pip install httpx2
Тест проходит в одиночку и падает в наборе
- Причина
- общее состояние между тестами
- Исправление
- свежее хранилище в фикстуре, сброс
dependency_overrides
Все закрытые маршруты отвечают 401
- Причина
- не задана
LEADS_API_KEYв окружении сервера - Исправление
- задайте переменную до
fastapi dev
Вместо 404 приходит 500, в логе ResponseValidationError
- Причина
return HTTPException(...)вместоraise: исключение вернулось как обычный объект и не прошло проверку модели ответа- Исправление
raise HTTPException(...)
Следующий шаг — заменить хранилище в памяти на базу данных: устройство ORM и миграций разобрано в отдельном уроке. Если нужен не только API, но и готовая админка для контента, посмотрите введение в Django: там модели, миграции и панель администратора идут «в комплекте».
Как проверено4 · пройдено1 · частичносверено 11 октября 2026 г.
- запущено, работает
pyproject.toml, app/main.py (3 части = весь файл), tests/test_leads.py
python -m venv в scratchpad, pip install "fastapi[standard]" pytest httpx2: pytest -v — 7 passed, без предупреждений; FastAPI 0.143.0, Pydantic 2.14.1 (pydantic-core 2.50.1), Starlette 1.7.0, pytest 9.1.1, httpx2 2.13.1, uvicorn 0.54.0, Python 3.12.10 (Windows 11). Код в статью вставлен скриптом из файлов проекта
- запущено, работает
fastapi dev app/main.py + curl-запросы
сервер на 127.0.0.1:8000: 201, 422 (три ошибки), 401, 200, 404 — вывод в статье реальный; /docs 200, /openapi.json — OpenAPI 3.1.0, 3 пути, параметры status/limit/x-api-key
- запущено, работает
утверждения раздела ошибок
без pythonpath: pytest → ModuleNotFoundError: No module named 'app', python -m pytest → 7 passed; без httpx2 — StarletteDeprecationWarning, 7 passed; return HTTPException → 500 / ResponseValidationError; типы ошибок greater_than_equal, less_than_equal, string_too_long; лишние поля id/status в POST игнорируются
- запущено, работает
упражнения: без status_code=201; 409 для закрытой заявки; reviews.py
1 failed (assert 200 == 201), 6 passed; ex2 — 8 passed; reviews.py + 2 теста — 2 passed
- частично
команды установки (bash)
выполнены на Windows эквивалентом (.venv/Scripts/python -m pip …), активация source .venv/bin/activate на Linux/macOS не запускалась — синтаксис сверен с документацией venv
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.