Связываем маршруты с действиями в базе данных: архитектура

Цели урока
  • Объяснить разделение main.py (маршруты, HTTP) и db.py (SQL) и почему оно важно
  • Сопоставлять каждый Endpoint с действием SQL и подходящим кодом состояния
  • Описать, что происходит в одном запросе — от браузера через FastAPI и db.py до базы и обратно
  • Реализовать и запустить GET /students и GET /students/{student_id} поверх базы данных

Что мы изучим?

Теперь мы соединяем всё: сервер из темы бэкенда и FastAPI и базу из темы баз данных. Этот урок сосредоточен на архитектуре, то есть на том, как соединяются части, и реализует первые два Endpoint-а (чтение). В следующем уроке — остальное.

Зачем это нужно?

В теме бэкенда и FastAPI сервер хранил студентов в RAM, и они исчезали при каждом перезапуске. Теперь сервер будет сохранять в базу данных: данные, которые остаются, с ограничениями и транзакциями. Это базовая архитектура почти любого бэкенда в мире: Client → API → Database.

Объяснение с основ

Два файла, две ответственности

students-api/
├── main.py     ← the API layer: routes, HTTP, validation, status codes
└── db.py       ← the data layer: SQL, connections, commit (from the databases topic)

MySQL in Docker (kodkod-mysql)  ← where the data lives
main.pydb.py
Знает оHTTP, запросах, кодах состояния, JSON, PydanticSQL, mysql.connector, таблицах, commit
Не знает оSQL, соединенияхHTTP, 404, FastAPI
Язык ошибокHTTPException(404)None, False, ValueError

Это то самое «базовое разделение кода между маршрутом и кодом доступа к базе данных», которого требует программа курса. И почему это хорошо?

  • Каждый файл маленький и сфокусированный (SRP, тема чистого кода).
  • Каждый слой проверяется сам по себе: db.py мы уже проверили в предыдущем уроке, без сервера.
  • Один слой заменяется, не трогая другой: переход на PostgreSQL изменит только db.py. Другой интерфейс (приложение командной строки) будет использовать тот же db.py.

Таблица соответствий

EndpointФункция в db.pySQLУспехСбои
GET /studentsget_all_students()SELECT200 + список—
GET /students/{id}get_student(id)SELECT WHERE id200 + dictNone → 404
POST /studentscreate_student(...)INSERT201 + dictValueError → 409. Проверка → 400 / 422
PUT /students/{id}update_student(id, ...)UPDATE WHERE id200 + dictNone → 404. ValueError → 409
DELETE /students/{id}delete_student(id)DELETE WHERE id204False → 404

Каждый маршрут делает три вещи: (1) получает и проверяет ввод (FastAPI + проверка), (2) вызывает одну функцию в db.py, (3) переводит результат в HTTP.

Один запрос от начала до конца

GET /students/2
   │
   ▼  Uvicorn receives the HTTP request (port 8000)
   ▼  FastAPI finds the route, converts "2" → 2 (int), calls get_student(2)
   ▼  main.get_student  →  db.get_student(2)
   ▼  db: open connection → SELECT ... WHERE id = %s (2) → fetchone → dict → close
   ▼  main: dict is not None → return it
   ▼  FastAPI: dict → JSON, status 200
   ▼  Uvicorn sends the HTTP response
{"id": 2, "name": "Avi", "age": 31, "email": "avi@mail.com"}

Весь курс на одном пути: HTTP и порт (тема о компьютере, операционной системе и сети), функции, dict и JSON (темы Python), классы и Pydantic (тема ООП), SQL (тема баз данных).

Аналогия: снова ресторан

main.py = официант: принимает заказы у посетителей, проверяет, что они осмысленны («пиццы в меню нет»), передаёт их на кухню и подаёт. db.py = кладовщик на кухне: знает, где каждый продукт, достаёт и кладёт. Официант не заходит в кладовую, а кладовщик не разговаривает с посетителями. Каждый — в своей роли.

Когда создаётся таблица?

db.init_db() вызывается один раз при запуске сервера (на верхнем уровне main.py). Благодаря IF NOT EXISTS это безопасно при каждом запуске: в первый раз таблица создаётся, а в остальные ничего не происходит.

Синтаксис

from fastapi import FastAPI, HTTPException

import db                                   # our data layer (same folder)

app = FastAPI(title="Students API")
db.init_db()                                # create the table once, on startup

@app.get("/students/{student_id}")
def get_student(student_id: int):
    student = db.get_student(student_id)    # 1 call to the data layer
    if student is None:                     # translate "not found" to HTTP
        raise HTTPException(status_code=404, detail="student not found")
    return student                          # dict → JSON (200)

Одинаковые имена в двух слоях: функция в main.py называется get_student, как и в db.py. Столкновения нет, потому что функцию базы вызывают через имя модуля: db.get_student(...) (вспомните модули!).

Первый пример кода

Настройка проекта (контейнер MySQL должен работать — docker start kodkod-mysql):

mkdir ~\dev\kodkod6\week-4\students-api
cd ~\dev\kodkod6\week-4\students-api
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install "fastapi[standard]" mysql-connector-python
git init

.gitignore:

.venv/
__pycache__/
.env

Скопируйте db.py из темы баз данных (полный CRUD) в папку. А main.py на этом этапе — только два Endpoint-а:

from fastapi import FastAPI, HTTPException

import db

app = FastAPI(title="Students API")
db.init_db()

@app.get("/students")
def list_students():
    return db.get_all_students()

@app.get("/students/{student_id}")
def get_student(student_id: int):
    student = db.get_student(student_id)
    if student is None:
        raise HTTPException(status_code=404, detail="student not found")
    return student

Добавляем данные для проверки (база пуста! POST ещё нет). seed.py:

import db

db.init_db()
for name, age, email in [("Dana", 24, "dana@mail.com"), ("Avi", 31, "avi@mail.com")]:
    try:
        print(db.create_student(name, age, email))
    except ValueError as error:
        print("Skipped:", error)
python seed.py
uvicorn main:app --reload

В браузере:

  • http://127.0.0.1:8000/students
  • http://127.0.0.1:8000/students/2
  • http://127.0.0.1:8000/students/99

Ответы:

[{"id":1,"name":"Dana","age":24,"email":"dana@mail.com"},{"id":2,"name":"Avi","age":31,"email":"avi@mail.com"}]
{"id":2,"name":"Avi","age":31,"email":"avi@mail.com"}
{"detail":"student not found"}

Разбор кода построчно

КодОбъяснение
import dbИмпорт слоя данных. main.py и db.py в одной папке (урок о модулях).
db.init_db()Выполняется один раз, когда Uvicorn загружает main.py: таблица готова до первого запроса.
def list_students(): return db.get_all_students()Маршрут в одну строку. Список (словарей) становится JSON.
student_id: intFastAPI преобразует и проверяет (/students/abc → 422, тема FastAPI).
db.get_student(student_id)Один вызов слоя данных.
if student is None: raise HTTPException(404)Перевод: «не найдено» из db.py → 404 из HTTP.
seed.pySeed = начальные данные. Отдельный скрипт, который использует тот же db.py. Доказательство, что слой не зависит от сервера.

А теперь важный эксперимент: остановите сервер (Ctrl + C) и запустите снова. Обновите /students. Данные на месте! В отличие от темы FastAPI, они в базе — на сервере MySQL в контейнере, который записывает их на накопитель (тема о компьютере!). Их можно увидеть и в Database Client.

Ещё один пример

Ещё один Endpoint, который использует db.py с параметром запроса: поиск. Добавьте в db.py функцию search_students из практики предыдущего урока, а в main.py:

@app.get("/students/search")
def search_students(name: str):
    if len(name.strip()) < 2:
        raise HTTPException(status_code=400, detail="search text must have at least 2 characters")
    return db.search_students(name)

Где в файле? /students/search — фиксированный путь, поэтому он должен идти до /students/{student_id} (тема FastAPI, ошибка 3!). Иначе search будет пойман как student_id → 422.

GET /students/search?name=av   → 200 [{"id": 2, "name": "Avi", ...}]
GET /students/search?name=a    → 400 {"detail": "search text must have at least 2 characters"}
GET /students/search           → 422 (name is required)

Обратите внимание на разделение работы: проверка (не меньше 2 символов) — в main.py, потому что это правило API. SQL (LIKE, lower) — в db.py.

Типичная ошибка

Ошибка 1: SQL внутри маршрута.

@app.get("/students/{student_id}")
def get_student(student_id: int):
    connection = mysql.connector.connect(
        host="localhost", user="root", password="kodkod", database="school")
    cursor = connection.cursor()
    cursor.execute("SELECT * FROM students WHERE id = %s", (student_id,))
    ...

Это «работает», но теперь SQL в двух местах, данные для подключения в двух местах, и маршрут, который делает всё. Маршрут = ввод + вызов db + перевод.

Ошибка 2: вернуть None из маршрута.

@app.get("/students/{student_id}")
def get_student(student_id: int):
    return db.get_student(student_id)

Для несуществующего студента возвращается 200 с null. Клиент подумает, что всё удалось. «Не найдено» = 404.

Ошибка 3: сервер не запускается — контейнер MySQL не работает. db.init_db() выполняется, когда Uvicorn загружает main.py, поэтому сервер падает уже при запуске:

mysql.connector.errors.InterfaceError: 2003: Can't connect to MySQL server on 'localhost:3306' ...

Проверьте через docker ps и запустите: docker start kodkod-mysql (тема баз данных!).

Ошибка 4: ModuleNotFoundError: No module named 'db'. db.py не в той же папке, что main.py, или вы запускаете uvicorn из другой папки. Запускайте из папки проекта.

Практика

Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.

Упражнение 1: Два слоя

Для каждой строки кода — к чему она относится: к main.py (слой HTTP) или к db.py (слой данных)?

  1. cursor.execute("SELECT * FROM students WHERE id = %s", (student_id,))
  2. raise HTTPException(status_code=404, detail="student not found")
  3. @app.get("/students/{student_id}")
  4. mysql.connector.connect(host="localhost", user="root", ...)
  5. return db.get_student(student_id)
Показать решение
  1. db.py — SQL.
  2. main.py — код HTTP.
  3. main.py — маршрут.
  4. db.py — данные для подключения к базе.
  5. main.py — сервер вызывает слой данных.

Правило: main.py говорит на HTTP, db.py говорит на SQL. Ни один из них не знает деталей другого.

Упражнение 2: Маршруты для чтения

У вас есть db.py из предыдущего урока (с init_db, get_all_students и get_student). Напишите main.py с:

  • db.init_db() при запуске сервера;
  • GET /students — все студенты;
  • GET /students/{student_id} — один студент или 404 с "student not found".
Показать решение

main.py

from fastapi import FastAPI, HTTPException

import db

app = FastAPI(title="Students API")
db.init_db()


@app.get("/students")
def list_students():
    return db.get_all_students()


@app.get("/students/{student_id}")
def get_student(student_id: int):
    student = db.get_student(student_id)
    if student is None:
        raise HTTPException(status_code=404, detail="student not found")
    return student

db.get_student возвращает None, когда студента нет, — и main.py переводит это в 404. В этом вся роль слоя HTTP.

Упражнение 3: Один запрос — от начала до конца

Расставьте по порядку то, что происходит в запросе GET /students/2:

  • (а) db.get_student(2) выполняет SELECT ... WHERE id = %s с (2,)
  • (б) FastAPI преобразует "2" из пути в int
  • (в) Ответ возвращается в виде JSON с кодом 200
  • (г) Вызывается main.get_student(2)
  • (д) Строка возвращается в виде словаря
Показать решение

Порядок: (б), (г), (а), (д), (в)

GET /students/2
  -> FastAPI: student_id = 2 (int)
  -> main.get_student(2)
  -> db.get_student(2)
  -> SELECT ... WHERE id = %s   (2,)
  -> {'id': 2, 'name': 'Avi', ...}
  -> JSON, 200

Упражнение 4: Endpoint поиска

Добавьте в main.py маршрут GET /students/search?name=..., который использует db.search_students. Имя короче 2 символов → 400. Где нужно определить маршрут и почему?

Показать решение

main.py (в дополнение к предыдущим маршрутам)

@app.get("/students/search")
def search_students(name: str):
    if len(name.strip()) < 2:
        raise HTTPException(status_code=400, detail="too short")
    return db.search_students(name)
  • ?name=av → 200 со списком. ?name=a → 400. Без name → 422 (обязательный параметр запроса, потому что у него нет значения по умолчанию).
  • Определите /students/search до /students/{student_id} — иначе FastAPI попытается преобразовать "search" в int и вернёт 422.

Упражнение 5: Что не так?

@app.get("/students/{student_id}")
def get_student(student_id: int):
    connection = mysql.connector.connect(
        host="localhost", user="root", password="kodkod", database="school")
    cursor = connection.cursor()
    cursor.execute("SELECT * FROM students WHERE id = %s", (student_id,))
    return cursor.fetchone()

Найдите в этом коде три проблемы.

Показать решение
  1. SQL внутри маршрута — смешение слоёв. Если мы изменим структуру таблицы, придётся искать SQL во всех маршрутах. Его место — в db.py.
  2. Соединение не закрывается — нет close(), и уж точно не в finally. На сервере, который работает всё время, это накапливается.
  3. Несуществующий студент возвращается как 200 с null — нужно проверить None и вернуть 404. А ещё без dictionary=True студент возвращается списком без имён полей, а не словарём.

Исправление: return db.get_student(student_id) с проверкой на None — как во втором упражнении.

Итоги

  • main.py = слой API: маршруты, проверка и коды состояния. db.py = слой данных: SQL, соединения и commit. Никакого SQL в маршрутах.
  • Соответствия: GET→SELECT, POST→INSERT (201), PUT→UPDATE, DELETE→DELETE (204). None/False → 404. ValueError → 409.
  • Маршрут = ввод → один вызов db → перевод в HTTP.
  • db.init_db() — один раз при запуске сервера (IF NOT EXISTS).
  • Фиксированные пути (/students/search) — до /students/{student_id}.
  • Данные переживают перезапуск сервера, потому что они в базе.

Официальная документация