חיבור Routes לפעולות Database — הארכיטקטורה
- להסביר את החלוקה main.py (Routes, HTTP) מול db.py (SQL) ולמה היא חשובה
- למפות כל Endpoint לפעולת SQL ולקוד הסטטוס המתאים
- לתאר מה קורה בבקשה אחת — מהדפדפן, דרך FastAPI ו-db.py, ועד המסד ובחזרה
- לממש ולהריץ את GET /students ואת GET /students/{student_id} מעל המסד
מה נלמד?
היום מחברים הכול: השרת מנושא Backend ו-FastAPI, המסד מימים 2–4. השיעור הזה מתמקד בארכיטקטורה, כלומר איך החלקים מתחברים, וממש את שני ה-Endpoints הראשונים (קריאה). בשיעור הבא, את השאר.
למה צריך את זה?
בנושא Backend ו-FastAPI השרת שמר סטודנטים ב-RAM, והם נעלמו בכל הפעלה מחדש. עכשיו השרת ישמור במסד: נתונים שנשארים, עם אילוצים ו-Transactions. זו הארכיטקטורה הבסיסית של כמעט כל Backend בעולם: 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 livesmain.py | db.py | |
|---|---|---|
| יודע על | HTTP, Requests, Status Codes, JSON, Pydantic | SQL, mysql.connector, טבלאות, commit |
| לא יודע על | SQL, חיבורים | HTTP, 404, FastAPI |
| שפת השגיאות | HTTPException(404) | None, False, ValueError |
זו “הפרדת קוד בסיסית בין Route לבין קוד הגישה למסד” שהסילבוס מבקש. ולמה זה טוב?
- כל קובץ קטן וממוקד (SRP, נושא Python–3).
- בודקים כל שכבה לבד: את
db.pyכבר בדקנו אתמול, בלי שרת. - מחליפים שכבה בלי לגעת בשנייה: מעבר ל-PostgreSQL ישנה רק את
db.py. ממשק אחר (אפליקציית שורת פקודה) ישתמש באותוdb.py.
טבלת המיפוי
| Endpoint | פונקציה ב-db.py | SQL | הצלחה | כישלונות |
|---|---|---|---|---|
GET /students | get_all_students() | SELECT | 200 + list | — |
GET /students/{id} | get_student(id) | SELECT WHERE id | 200 + dict | None → 404 |
POST /students | create_student(...) | INSERT | 201 + dict | ValueError ← 409. ולידציה ← 400 / 422 |
PUT /students/{id} | update_student(id, ...) | UPDATE WHERE id | 200 + dict | None → 404. ValueError → 409 |
DELETE /students/{id} | delete_student(id) | DELETE WHERE id | 204 | False → 404 |
כל Route עושה שלושה דברים: (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 ו-Port (נושא מחשב, מערכת הפעלה ורשת), פונקציות ו-dict ו-JSON (נושא Python), Classes ו-Pydantic (נושא OOP), SQL (נושא Backend ומסדי נתונים).
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(...) (נושא Python!).
דוגמת קוד ראשונה
הקמת הפרויקט (הקונטיינר של 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, בשלב הזה רק שני Endpoints:
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/studentshttp://127.0.0.1:8000/students/2http://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 באותה תיקייה (נושא Python). |
db.init_db() | רץ פעם אחת כש-Uvicorn טוען את main.py: הטבלה מוכנה לפני הבקשה הראשונה. |
def list_students(): return db.get_all_students() | Route בשורה אחת. הרשימה (של dicts) הופכת ל-JSON. |
student_id: int | FastAPI ממיר וּבודק (/students/abc → 422, נושא Backend ו-FastAPI). |
db.get_student(student_id) | קריאה אחת לשכבת הנתונים. |
if student is None: raise HTTPException(404) | תרגום: “לא נמצא” של db.py → 404 של HTTP. |
seed.py | Seed = נתוני התחלה. סקריפט נפרד, שמשתמש באותו db.py. הוכחה שהשכבה לא תלויה בשרת. |
ועכשיו הניסוי החשוב: עצרו את השרת (Ctrl + C), והפעילו שוב. רעננו את /students. הנתונים שם! בניגוד לנושא Backend ו-FastAPI, הם במסד — בשרת ה-MySQL שבקונטיינר, שכותב אותם ל-Storage (נושא מחשב, מערכת הפעלה ורשת!). אפשר גם לראות אותם ב-Database Client.
דוגמה נוספת
Endpoint נוסף, שמשתמש ב-db.py עם Query Parameter: חיפוש. הוסיפו ל-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} (נושא Backend ו-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 בתוך ה-Route.
@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 בשני מקומות, פרטי חיבור בשני מקומות, ו-Route שעושה הכול. Route = קלט + קריאה ל-db + תרגום.
הטעות 2: להחזיר None מה-Route.
@app.get("/students/{student_id}")
def get_student(student_id: int):
return db.get_student(student_id)לסטודנט שלא קיים מוחזר 200 עם null. ה-Client יחשוב שההצלחה. “לא נמצא” = 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 (נושא מסדי נתונים ו-MySQL!).
הטעות 4: ModuleNotFoundError: No module named 'db'. db.py לא באותה תיקייה כמו main.py, או שמריצים את uvicorn מתיקייה אחרת. מריצים מתיקיית הפרויקט.
תרגול
התרגילים מבוססים רק על מה שנלמד בשיעור הזה ובשיעורים שלפניו. נסו לבד, ורק אז פתחו את הפתרון.
תרגיל 1: שתי שכבות
לכל שורת קוד — האם היא שייכת ל-main.py (שכבת ה-HTTP) או ל-db.py (שכבת הנתונים)?
cursor.execute("SELECT * FROM students WHERE id = %s", (student_id,))raise HTTPException(status_code=404, detail="student not found")@app.get("/students/{student_id}")mysql.connector.connect(host="localhost", user="root", ...)return db.get_student(student_id)
הצג פתרון
- db.py — SQL.
- main.py — קוד HTTP.
- main.py — Route.
- db.py — פרטי החיבור למסד.
- main.py — השרת קורא לשכבת הנתונים.
הכלל: main.py מדבר HTTP, db.py מדבר SQL. אף אחד מהם לא יודע על הפרטים של השני.
תרגיל 2: Routes לקריאה
יש לכם את 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 studentdb.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. איפה צריך להגדיר את ה-Route, ולמה?
הצג פתרון
main.py (בתוספת ל-Routes הקודמים)
@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 (Query חובה, כי אין לו ברירת מחדל).- את
/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()מצאו שלוש בעיות בקוד הזה.
הצג פתרון
- SQL בתוך ה-Route — ערבוב שכבות. אם נשנה את מבנה הטבלה, נצטרך לחפש SQL בכל ה-Routes. המקום שלו ב-
db.py. - החיבור לא נסגר — אין
close(), ובוודאי לא ב-finally. בשרת שרץ כל הזמן, זה מצטבר. - סטודנט שלא קיים חוזר כ-200 עם
null— צריך לבדוקNoneולהחזיר 404. וגם בליdictionary=True, הסטודנט חוזר כרשימה בלי שמות שדות, במקום מילון.
התיקון: return db.get_student(student_id) עם בדיקת None — כמו בתרגיל השני.
סיכום
main.py= שכבת ה-API: Routes, ולידציה וקודי סטטוס.db.py= שכבת הנתונים: SQL, חיבורים ו-commit. אין SQL ב-Routes.- מיפוי: GET→SELECT, POST→INSERT (201), PUT→UPDATE, DELETE→DELETE (204).
None/False→ 404.ValueError→ 409. - Route = קלט ← קריאה אחת ל-db ← תרגום ל-HTTP.
db.init_db()פעם אחת בעליית השרת (IF NOT EXISTS).- נתיבים קבועים (
/students/search) לפני/students/{student_id}. - הנתונים שורדים הפעלה מחדש של השרת, כי הם במסד.
דוקומנטציה רשמית
- FastAPI — Bigger Applications — חלוקת הקוד לקבצים
- FastAPI — Handling Errors — HTTPException כשרשומה לא נמצאה