Students API — CRUD מלא: POST, GET, PUT ו-DELETE מול המסד

מטרות השיעור
  • להוסיף POST, PUT ו-DELETE שמחוברים לפונקציות ב-db.py
  • לבצע ולידציה עסקית בפונקציה משותפת, ולהחזיר 400
  • לתרגם את החוזה של db.py לקודים — 201, 204, 404 ו-409
  • להריץ את השרת המלא ולקרוא נכון כל תשובה

מה נלמד?

משלימים את ה-API לפי טבלת המיפוי:

  • POST /students → INSERT → 201.
  • PUT /students/{id} → UPDATE → 200 / 404 / 409.
  • DELETE /students/{id} → DELETE → 204 / 404.
  • מודל קלט (Pydantic) וולידציה עסקית משותפת (400).

למה צריך את זה?

זה הפרויקט הקצר שמסכם את Server + Database + CRUD, כמו שהסילבוס מגדיר. בסוף השיעור יש API אמיתי: אפליקציה, Frontend או שירות אחר יכולים ליצור, לקרוא, לעדכן ולמחוק סטודנטים, והנתונים נשמרים במסד.

הסבר מהבסיס

מודל הקלט

class StudentIn(BaseModel):
    name: str
    age: int
    email: str

StudentIn מתאר מה ה-Client שולח ב-POST וב-PUT. אין בו id, כי את ה-id נותן המסד (POST) או ה-Path (PUT). השם ...In הוא מוסכמה נפוצה: “המודל של מה שנכנס”.

FastAPI מוודא מבנה וטיפוסים (חסר שדה, טקסט במקום מספר ‏←‏ 422, אוטומטית).

ולידציה עסקית — 400

כללים (גיל 16–120, שם של לפחות 2 תווים, אימייל עם @) הם שלנו. בודקים אותם בפונקציה אחת, ש-POST ו-PUT קוראות לה (DRY):

def validate_student(student):
    if student.age < 16 or student.age > 120:
        raise HTTPException(status_code=400, detail="age must be between 16 and 120")

שלוש שכבות של בדיקות, וכל אחת עם התפקיד שלה:

שכבהבודקתקוד
Pydantic (FastAPI)מבנה וטיפוסים422
validate_student (main.py)כללים עסקיים400
אילוצי המסד (db.py)ייחודיות, חובה409 (דרך ValueError)

204 No Content

ב-DELETE מצליח אין מה להחזיר: הסטודנט נמחק. status_code=204 אומר “הצלחתי, בלי גוף”. הפונקציה פשוט לא מחזירה כלום (None), ו-FastAPI שולח תשובה ריקה.

תרגום של ValueError

try:
    return db.create_student(...)
except ValueError as error:
    raise HTTPException(status_code=409, detail=str(error))

str(error) = ההודעה ש-db.py כתבה (“a student with email … already exists”). ההודעה עוברת שתי שכבות: מהמסד (IntegrityError), דרך db.py (ValueError), אל ה-Client (409 + detail).

תחביר

@app.post("/students", status_code=201)
def create_student(student: StudentIn):            # body
    ...


@app.put("/students/{student_id}")
def update_student(student_id: int, student: StudentIn):   # path + body together
    ...


@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
    ...                                            # return nothing on success

ב-PUT יש גם Path Parameter וגם Body. FastAPI מבחין ביניהם: student_id בנתיב, ו-student (מודל) ב-Body.

דוגמת קוד ראשונה

main.py המלא:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

import db

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


class StudentIn(BaseModel):
    name: str
    age: int
    email: str


def validate_student(student):
    if len(student.name.strip()) < 2:
        raise HTTPException(status_code=400, detail="name must have at least 2 characters")
    if student.age < 16 or student.age > 120:
        raise HTTPException(status_code=400, detail="age must be between 16 and 120")
    if "@" not in student.email:
        raise HTTPException(status_code=400, detail="invalid email")


@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


@app.post("/students", status_code=201)
def create_student(student: StudentIn):
    validate_student(student)
    try:
        return db.create_student(student.name, student.age, student.email)
    except ValueError as error:
        raise HTTPException(status_code=409, detail=str(error))


@app.put("/students/{student_id}")
def update_student(student_id: int, student: StudentIn):
    validate_student(student)
    try:
        updated = db.update_student(student_id, student.name, student.age, student.email)
    except ValueError as error:
        raise HTTPException(status_code=409, detail=str(error))
    if updated is None:
        raise HTTPException(status_code=404, detail="student not found")
    return updated


@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
    if not db.delete_student(student_id):
        raise HTTPException(status_code=404, detail="student not found")

הרצה: uvicorn main:app --reload, מתיקיית הפרויקט, עם ה-venv פעיל.

פירוק הקוד שורה אחר שורה

חלקהסבר
validate_student(student)ולידציה אחת לשני Endpoints. raise HTTPException(400) עוצר את הבקשה לפני שנוגעים במסד.
POSTולידציה ‏←‏ db.create_student ‏←‏ מחזיר את הסטודנט מהמסד (עם id) ו-201. ValueError (אימייל קיים) ‏←‏ 409.
PUTולידציה ‏←‏ db.update_student. שני כישלונות שונים: ValueError ‏←‏ 409, ו-None ‏←‏ 404.
updated = ... בתוך try, הבדיקה של None מחוצה לוה-try עוטף רק את מה שעלול לזרוק ValueError (נושא Python: try קטן).
DELETEdb.delete_student מחזירה False ‏←‏ 404. הצלחה ‏←‏ אין return ‏←‏ 204 בלי גוף.
if not db.delete_student(...)קריאה ובדיקה בשורה אחת. המחיקה מתבצעת בכל מקרה, ו-not הופך את False ל"נכשל".

סך הכול: קובץ של כ-60 שורות, שמכיל API מלא. כל Route קצר, כי כל העבודה הכבדה (SQL, אילוצים) נמצאת ב-db.py, וכל ההמרות (JSON, טיפוסים) ב-FastAPI.

דוגמה נוספת

כל התשובות האמיתיות של השרת — רצף בקשות על מסד ריק (נבדק עם הקוד למעלה):

#בקשהBodyקודתשובה
1POST /students{"name": "Dana", "age": 24, "email": "dana@mail.com"}201{"id": 1, "name": "Dana", "age": 24, "email": "dana@mail.com"}
2POST /students{"name": "Avi", "age": 31, "email": "avi@mail.com"}201{"id": 2, ...}
3POST /students{"name": "Dana2", "age": 20, "email": "dana@mail.com"}409{"detail": "a student with email dana@mail.com already exists"}
4POST /students{"name": "Kid", "age": 7, "email": "kid@mail.com"}400{"detail": "age must be between 16 and 120"}
5POST /students{"name": "Noa"}422{"detail": [{"type": "missing", "loc": ["body", "age"], ...}, {... "email" ...}]}
6POST /students{"name": "Noa", "age": "abc", "email": "n@m.com"}422{"detail": [{"type": "int_parsing", "loc": ["body", "age"], ...}]}
7GET /students—200[{"id": 1, ...Dana}, {"id": 2, ...Avi}]
8GET /students/99—404{"detail": "student not found"}
9GET /students/abc—422{"detail": [{"type": "int_parsing", "loc": ["path", "student_id"], ...}]}
10PUT /students/2{"name": "Avi Levi", "age": 32, "email": "avi@mail.com"}200{"id": 2, "name": "Avi Levi", "age": 32, "email": "avi@mail.com"}
11PUT /students/99{"name": "Ghost", "age": 30, "email": "g@mail.com"}404{"detail": "student not found"}
12DELETE /students/1—204(ריק)
13DELETE /students/1—404{"detail": "student not found"}
14GET /students—200[{"id": 2, "name": "Avi Levi", "age": 32, "email": "avi@mail.com"}]

ולוג השרת (ב-Terminal של uvicorn) מציג שורה לכל בקשה, למשל:

INFO:     127.0.0.1:49197 - "POST /students HTTP/1.1" 201 Created
INFO:     127.0.0.1:49198 - "GET /students/999 HTTP/1.1" 404 Not Found

קריאה נכונה של הטבלה: לכל אחת מהשורות יש סיבה בשכבה אחרת:

  • 422 (שורות 5, 6, 9) — FastAPI ו-Pydantic. הקוד שלנו לא רץ.
  • 400 (שורה 4) — validate_student.
  • 409 (שורה 3) — המסד (UNIQUE), דרך db.py.
  • 404 (שורות 8, 11, 13) — db.py החזירה None/False.

טעות נפוצה

הטעות 1: לשכוח status_code=201 ב-POST, או 204 ב-DELETE. הכול “עובד”, אבל עם 200. ה-Client (ובדיקות אוטומטיות) מצפים לקודים הנכונים.

הטעות 2: להחזיר תוכן מ-DELETE עם 204.

@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
    db.delete_student(student_id)
    return {"message": "deleted"}

לפי תקן HTTP, ב-204 אסור שיהיה גוף. גרסאות שונות של FastAPI מטפלות בזה אחרת (שגיאה, או גוף שנזרק). או 204 בלי return, או 200 עם הודעה. לא שניהם.

הטעות 3: לשכוח את הבדיקה של None ב-PUT. return db.update_student(...) מחזיר 200 עם null לסטודנט שלא קיים.

הטעות 4: except Exception ב-Route.

try:
    return db.create_student(...)
except Exception:
    raise HTTPException(status_code=409, detail="conflict")

באג אמיתי (למשל טבלה חסרה) יוחזר כ"409 Conflict", ויטעה את כולם. תופסים רק ValueError, וכל השאר יגיע כ-500, כדי שנראה אותו ונתקן.

הטעות 5: ולידציה כפולה ושונה. בדיקת גיל ב-POST “מעל 16”, וב-PUT “מעל 18”. פונקציה אחת (validate_student).

תרגול

התרגילים מבוססים רק על מה שנלמד בשיעור הזה ובשיעורים שלפניו. נסו לבד, ורק אז פתחו את הפתרון.

תרגיל 1: POST — יצירה

הוסיפו ל-main.py מודל StudentIn (name, age, email) ו-POST /students שמחזיר 201. אימייל כפול (ValueError מ-db.create_student) → 409.

הצג פתרון

main.py

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

import db

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


class StudentIn(BaseModel):
    name: str
    age: int
    email: str


@app.post("/students", status_code=201)
def create_student(student: StudentIn):
    try:
        return db.create_student(student.name, student.age, student.email)
    except ValueError as error:
        raise HTTPException(status_code=409, detail=str(error))
  • יצירה מוצלחת ‏←‏ 201 עם הסטודנט (כולל id).
  • אותו אימייל שוב ‏←‏ 409 עם "a student with email ... already exists".
  • גוף בלי email → 422, עוד לפני שהפונקציה רצה.

תרגיל 2: כללי ולידציה

כתבו validate_student(student) שזורקת HTTPException עם 400 אם: השם קצר מ-2 תווים, הגיל מחוץ ל-16 עד 120, או שאין @ באימייל. הפעילו אותה ב-POST לפני השמירה.

הצג פתרון

main.py (בתוספת)

MIN_NAME_LENGTH = 2
MIN_AGE = 16
MAX_AGE = 120


def validate_student(student):
    if len(student.name.strip()) < MIN_NAME_LENGTH:
        raise HTTPException(status_code=400, detail="name too short")
    if student.age < MIN_AGE or student.age > MAX_AGE:
        raise HTTPException(status_code=400, detail="age must be 16-120")
    if "@" not in student.email:
        raise HTTPException(status_code=400, detail="invalid email")


@app.post("/students", status_code=201)
def create_student(student: StudentIn):
    validate_student(student)
    try:
        return db.create_student(student.name, student.age, student.email)
    except ValueError as error:
        raise HTTPException(status_code=409, detail=str(error))

הבדיקות שלנו רצות לפני שנוגעים במסד: קלט פסול לא מגיע לשכבת הנתונים בכלל.

תרגיל 3: PUT — עדכון

הוסיפו PUT /students/{student_id}: ולידציה (400), סטודנט שלא קיים (404), אימייל שתפוס על ידי סטודנט אחר (409), ואחרת 200 עם הסטודנט המעודכן.

הצג פתרון

main.py (בתוספת)

@app.put("/students/{student_id}")
def update_student(student_id: int, student: StudentIn):
    validate_student(student)
    try:
        updated = db.update_student(student_id, student.name, student.age, student.email)
    except ValueError as error:
        raise HTTPException(status_code=409, detail=str(error))
    if updated is None:
        raise HTTPException(status_code=404, detail="student not found")
    return updated

כל תוצאה של db.update_student מתורגמת לקוד אחר: מילון ‏←‏ 200, None ‏←‏ 404, ValueError ‏←‏ 409.

תרגיל 4: DELETE — 204

הוסיפו DELETE /students/{student_id} שמחזיר 204 כשהמחיקה הצליחה, ו-404 כשאין סטודנט כזה. למה ב-204 לא מחזירים גוף?

הצג פתרון

main.py (בתוספת)

@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
    if not db.delete_student(student_id):
        raise HTTPException(status_code=404, detail="student not found")

204 No Content אומר בדיוק “הצלחתי, ואין מה להחזיר”. לכן הפונקציה לא מחזירה כלום. מחיקה שנייה של אותו id → 404.

תרגיל 5: מי מחליט על איזה קוד?

לכל בקשה — איזה קוד יחזור, ומי מחזיר אותו: Pydantic, הקוד שלנו (validate_student), או התרגום מ-db?

1. POST /students   {"name": "Noa", "age": "abc", "email": "noa@mail.com"}
2. POST /students   {"name": "Noa", "age": 10, "email": "noa@mail.com"}
3. POST /students   {"name": "Noa", "age": 22, "email": "dana@mail.com"}   (taken)
4. PUT  /students/99  {"name": "Noa", "age": 22, "email": "noa@mail.com"}
5. DELETE /students/1   (exists)
הצג פתרון
בקשהקודמי
1422Pydantic — גיל אינו מספר
2400validate_student — כלל עסקי
3409תרגום של ValueError מ-db — כפילות במסד
4404db.update_student החזירה None
5204הצלחה בלי גוף

סיכום

  • POST → db.create_student → 201 + הסטודנט. ValueError → 409.
  • PUT (Path + Body) → db.update_student → 200. None → 404. ValueError → 409.
  • DELETE → db.delete_student → 204 בלי גוף. False → 404.
  • שלוש שכבות בדיקה: 422 (Pydantic, מבנה), 400 (validate_student, כללים), 409 (המסד, ייחודיות).
  • ולידציה בפונקציה אחת ל-POST ול-PUT. תופסים רק ValueError, וכל השאר נשאר 500.
  • main.py של כ-60 שורות = API מלא, כי כל שכבה עושה את שלה.

דוקומנטציה רשמית