FastAPI: Path Params, Query, Body ו-HTTPException

מטרות השיעור
  • להבין Type Hints ואיך FastAPI משתמש בהם להמרה ולולידציה
  • להגדיר Path Parameter ו-Query Parameters (חובה, ברירת מחדל ואופציונלי)
  • להגדיר Body עם מודל Pydantic (BaseModel) ולקבל אותו ב-POST
  • להחזיר קודים נכונים — 201 ביצירה, 404 עם HTTPException — ולהבין את 422 האוטומטי

מה נלמד?

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

קלטאיך FastAPI יודע
Path Parameterשם בסוגריים מסולסלים בנתיב: "/students/{student_id}"
Query Parameterפרמטר של הפונקציה שלא מופיע בנתיב
Bodyפרמטר שהטיפוס שלו הוא מודל Pydantic

ובנוסף: Type Hints, status_code, HTTPException, ושגיאת 422 האוטומטית.

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

בלי קלט, שרת יכול רק להחזיר תמיד את אותו דבר. עם קלט, הוא יכול להחזיר סטודנט ספציפי, לסנן, וליצור סטודנטים חדשים. ובונוס ענק: FastAPI בודק ומתרגם את הקלט בשבילנו. בנושא מחשב, מערכת הפעלה ורשת, שרת המחשבון קרס על "hello". כאן, קלט שגוי יקבל אוטומטית 422 עם הסבר מדויק, והשרת ימשיך לעבוד.

הסבר מהבסיס

Type Hints

Type Hint (רמז טיפוס) = הערה בקוד שאומרת מה הטיפוס הצפוי של פרמטר:

def get_student(student_id: int):

student_id: int = “הפרמטר הזה אמור להיות int”. ב-Python רגיל, Type Hints לא נאכפים: הם רק תיעוד, שעוזר ל-VS Code ולמפתחים. אבל FastAPI קורא אותם ומשתמש בהם:

  1. המרה — מה-URL מגיעה המחרוזת "7", ו-FastAPI ממיר אותה ל-7, כי ביקשנו int.
  2. ולידציה — אם הגיע "abc", FastAPI לא קורא לפונקציה, אלא מחזיר 422 עם הסבר.
  3. תיעוד — /docs מציג את הטיפוס של כל פרמטר.

Path Parameter

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

{student_id} בנתיב = “החלק הזה משתנה”. FastAPI מעביר אותו לפרמטר באותו שם בדיוק.

Query Parameters

כל פרמטר של הפונקציה שלא מופיע בנתיב (ואינו מודל) = Query Parameter.

def list_students(city: str | None = None, min_age: int = 0, limit: int = 10):
הגדרהמשמעות
min_age: int = 0אופציונלי, עם ברירת מחדל 0.
limit: int (בלי =)חובה. בלעדיו ‏←‏ 422.
city: str | None = Noneאופציונלי, בלי ערך ברירת מחדל: “או מחרוזת, או None”. None = “לא נשלח, ולכן לא מסננים”.

str | None נקרא “str או None”. זה Type Hint שאומר שמותרים שני טיפוסים.

Body עם Pydantic

Pydantic היא ספרייה (מותקנת יחד עם FastAPI) שמגדירה מבנה של נתונים עם טיפוסים. יוצרים Class שיורשת מ-BaseModel (Inheritance, נושא OOP!):

from pydantic import BaseModel


class StudentIn(BaseModel):
    name: str
    age: int
    city: str = "Unknown"
  • כל שורה = שדה, עם טיפוס. שדה עם = הוא אופציונלי, עם ברירת מחדל.
  • אין __init__! BaseModel בונה אותו לבד מתוך השדות.
  • כשפרמטר של פונקציה הוא מהטיפוס הזה, FastAPI מבין שזה Body: קורא את ה-JSON, בודק שכל השדות קיימים ומהטיפוס הנכון, ובונה אובייקט. ניגשים לשדות עם נקודה: student.name.
אנלוגיה: טופס עם שדות חובה

מודל Pydantic הוא טופס מודפס: “שם (חובה, טקסט). גיל (חובה, מספר). עיר (אופציונלי)”. הפקיד (FastAPI) לא מעביר לטיפול (לפונקציה שלכם) טופס שחסר בו שדה חובה, או שכתוב בו “עשרים” בשדה הגיל. הוא מחזיר אותו מיד למגיש (422) עם סימון של השדה הבעייתי. הפונקציה שלכם מקבלת רק טפסים תקינים.

status_code ו-HTTPException

  • @app.post("/students", status_code=201) — קוד ההצלחה של ה-Endpoint הזה הוא 201 Created, ולא 200.
  • raise HTTPException(status_code=404, detail="...") — עוצר את הפונקציה ומחזיר ל-Client קוד שגיאה עם JSON {"detail": "..."}. זה raise רגיל (נושא Python!), ו-FastAPI תופס אותו והופך אותו לתשובת HTTP.

תחביר

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()


class ItemIn(BaseModel):              # body model
    name: str
    price: float
    in_stock: bool = True


@app.get("/items/{item_id}")          # path parameter
def get_item(item_id: int):
    ...
    raise HTTPException(status_code=404, detail="item not found")


@app.get("/items")                    # query parameters
def list_items(max_price: float | None = None, limit: int = 10):
    ...


@app.post("/items", status_code=201)  # body
def create_item(item: ItemIn):
    item.name, item.price             # use the validated fields

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

API סטודנטים בזיכרון (ב-RAM. עוד אין Database, הוא יגיע בנושא מסדי נתונים ו-MySQL). week-4/hello-api/students_memory.py:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="Students (in memory)")

students = [
    {"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
    {"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
    {"id": 3, "name": "Noa", "age": 22, "city": "Haifa"},
]


class StudentIn(BaseModel):
    name: str
    age: int
    city: str = "Unknown"


@app.get("/students")
def list_students(city: str | None = None, min_age: int = 0, limit: int = 10):
    result = []
    for student in students:
        if city is not None and student["city"] != city:
            continue
        if student["age"] < min_age:
            continue
        result.append(student)
    return result[:limit]


@app.get("/students/{student_id}")
def get_student(student_id: int):
    for student in students:
        if student["id"] == student_id:
            return student
    raise HTTPException(status_code=404, detail="student " + str(student_id) + " not found")


@app.post("/students", status_code=201)
def create_student(student: StudentIn):
    new_id = max(s["id"] for s in students) + 1
    new_student = {"id": new_id, "name": student.name, "age": student.age, "city": student.city}
    students.append(new_student)
    return new_student

הרצה: uvicorn students_memory:app --reload (שימו לב: שם הקובץ השתנה!).

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

קודהסבר
students = [...]“Database” זמני ב-RAM: רשימת dicts (נושא Python). נמחק בכל הפעלה מחדש (נושא מחשב, מערכת הפעלה ורשת!). בנושא מסדי נתונים ו-MySQL נעבור ל-Database אמיתי.
class StudentIn(BaseModel):מודל ה-Body: מה ה-Client שולח ביצירה. אין בו id, כי השרת קובע את ה-id.
city: str = "Unknown"שדה אופציונלי ב-Body.
list_students(city: str | None = None, min_age: int = 0, limit: int = 10)שלושה Query Parameters, כולם אופציונליים. /students?city=Haifa&min_age=23.
if city is not None and ...: continueסינון רק אם city נשלח. continue מדלג לסטודנט הבא.
return result[:limit]Slicing (נושא Python): לכל היותר limit תוצאות.
@app.get("/students/{student_id}") + student_id: intPath Parameter, עם המרה ל-int.
raise HTTPException(status_code=404, ...)לא נמצא, ולכן 404 עם הודעה.
@app.post(..., status_code=201)יצירה מחזירה 201.
create_student(student: StudentIn)הטיפוס הוא מודל, ולכן זה Body. FastAPI מפענח וּבודק.
student.name, student.ageגישה לשדות של אובייקט (נושא OOP).
max(s["id"] for s in students) + 1מזהה חדש: הגדול ביותר + 1.

דוגמה נוספת

בדיקה מלאה של כל סוגי הקלט, והתשובות האמיתיות. (אפשר לבדוק גם ב-/docs, או עם curl.)

בקשהקודתשובה
GET /students200כל השלושה
GET /students?city=Haifa200דנה ונועה
GET /students?min_age=23&limit=1200[{"id": 1, "name": "Dana", ...}]
GET /students/2200{"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"}
GET /students/42404{"detail": "student 42 not found"}
GET /students/abc422ראו למטה
GET /students?min_age=old422ראו למטה
POST /students + {"name": "Tom", "age": 27}201{"id": 4, "name": "Tom", "age": 27, "city": "Unknown"}
POST /students + {"name": "Rina", "age": "twenty"}422ראו למטה

שגיאת 422 — איך היא נראית (GET /students/abc):

{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["path", "student_id"],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "abc"
    }
  ]
}

קריאת 422:

  • loc (Location) — איפה הבעיה: ["path", "student_id"]. ב-Query היה ["query", "min_age"], וב-Body היה ["body", "age"].
  • msg — מה הבעיה.
  • input — מה התקבל בפועל.
  • הפונקציה שלנו לא רצה בכלל. FastAPI עצר את הבקשה לפני כן. אין צורך ב-try/except על int().

ואחרי POST של Tom: GET /students?city=Unknown מחזיר את Tom, כי ה-city קיבל ברירת מחדל. אבל עצרו את השרת והפעילו מחדש: Tom נעלם. הנתונים היו ב-RAM. זו בדיוק הסיבה ל-Database (מחר!).

טעות נפוצה

הטעות 1: שם לא תואם בין הנתיב לפרמטר.

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

FastAPI לא מוצא פרמטר בשם student_id, ומתייחס ל-id כ-Query Parameter חובה. כל בקשה ל-/students/7 תחזיר 422 ("loc": ["query", "id"], missing). השם בסוגריים המסולסלים חייב להיות זהה לשם הפרמטר.

הטעות 2: Path Parameter בלי Type Hint.

def get_student(student_id):
    for student in students:
        if student["id"] == student_id:

בלי : int, הפרמטר מגיע כמחרוזת "2", ו-2 == "2" הוא False. התוצאה: תמיד 404, בלי שום שגיאה. Logical Error שקט.

הטעות 3: סדר Routes עם נתיב קבוע ונתיב עם פרמטר.

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


@app.get("/students/top")
def top_students(): ...

GET /students/top → 422! FastAPI בודק Routes לפי הסדר, ו-/students/{student_id} “תופס” את top כ-student_id, ונכשל בהמרה ל-int. נתיבים קבועים לפני נתיבים עם פרמטרים.

הטעות 4: return במקום raise ל-HTTPException.

return HTTPException(status_code=404, detail="not found")

מחזיר 200, עם אובייקט מוזר בתוך ה-JSON. שגיאה זורקים (raise), ולא מחזירים (נושא Python).

הטעות 5: לכלול id במודל של ה-Body. אם StudentIn כולל id: int, ה-Client חייב לשלוח id, ויכול לשלוח id שכבר קיים. את המזהה השרת קובע. מודל ה-Body מכיל רק את מה שה-Client אמור לשלוח.

תרגול

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

תרגיל 1: Path Parameter עם Type Hint

כתבו API עם רשימת סטודנטים בזיכרון (מילונים עם id, name, age, city), ו-Route GET /students/{student_id} שמחזיר את הסטודנט, או 404 עם detail של "student not found".

  1. מה יקרה בבקשה GET /students/abc?
הצג פתרון
from fastapi import FastAPI, HTTPException

app = FastAPI(title="Students API")

students = [
    {"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
    {"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
]


@app.get("/students/{student_id}")
def get_student(student_id: int):
    for student in students:
        if student["id"] == student_id:
            return student
    raise HTTPException(status_code=404, detail="student not found")
  • GET /students/2 → 200 עם אבי. GET /students/9 → 404 {"detail": "student not found"}.
  • GET /students/abc → 422: ה-Type Hint student_id: int גורם ל-FastAPI לבדוק ולהמיר בעצמו. הפונקציה בכלל לא רצה.

תרגיל 2: Query Parameters

הוסיפו Route GET /students שמקבל Query Parameter אופציונלי city (ברירת מחדל None), ומחזיר רק את הסטודנטים מהעיר הזו — או את כולם אם לא נשלחה עיר.

הצג פתרון
from fastapi import FastAPI

app = FastAPI(title="Students API")

students = [
    {"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
    {"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
]


@app.get("/students")
def list_students(city: str = None):
    if city is None:
        return students
    result = []
    for student in students:
        if student["city"] == city:
            result.append(student)
    return result
  • GET /students ‏←‏ שני הסטודנטים.
  • GET /students?city=Haifa ‏←‏ רק דנה.

פרמטר בפונקציה שהוא לא חלק מהנתיב הופך אוטומטית ל-Query Parameter. ערך ברירת מחדל הופך אותו לאופציונלי.

תרגיל 3: POST עם Body

הוסיפו מודל Pydantic בשם StudentIn (name, age, city) ו-Route POST /students שמחזיר 201, נותן לסטודנט id חדש ומוסיף אותו לרשימה. גיל מתחת ל-16 → 400.

הצג פתרון
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="Students API")

MIN_AGE = 16

students = [
    {"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
]


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


@app.post("/students", status_code=201)
def create_student(student: StudentIn):
    if student.age < MIN_AGE:
        raise HTTPException(status_code=400, detail="age must be at least 16")
    new_student = {"id": len(students) + 1, "name": student.name,
                   "age": student.age, "city": student.city}
    students.append(new_student)
    return new_student
  • POST /students עם {"name": "Noa", "age": 22, "city": "Eilat"} → 201 עם id: 2.
  • גיל 12 → 400. גוף בלי age → 422 — Pydantic דוחה מבנה שגוי לפני שהפונקציה רצה.

תרגיל 4: 422 מול 400

  1. מה ההבדל בין 422 ל-400 ב-FastAPI? מי מחזיר כל אחד?
  2. לכל בקשה — איזה קוד יחזור?
POST /students  {"name": "Noa", "age": "abc", "city": "Eilat"}
POST /students  {"name": "Noa", "age": 10, "city": "Eilat"}
POST /students  {"name": "Noa", "city": "Eilat"}
הצג פתרון
  1. 422 — FastAPI ו-Pydantic מחזירים אותו אוטומטית כשהמבנה או הטיפוס שגויים. 400 — הקוד שלנו מחזיר אותו כשהערך תקין מבחינת טיפוס, אבל לא עומד בכלל עסקי.
  2. התשובות:
    • "age": "abc" → 422 (גיל אינו מספר).
    • "age": 10 → 400 (מספר תקין, אבל מתחת לגיל המינימלי — הכלל שלנו).
    • בלי age → 422 (שדה חובה חסר).

תרגיל 5: סטטיסטיקה

הוסיפו Route GET /students/stats שמחזיר את מספר הסטודנטים ואת הגיל הממוצע (מעוגל לספרה אחת). איפה צריך להגדיר אותו ביחס ל-GET /students/{student_id}, ולמה?

הצג פתרון
from fastapi import FastAPI, HTTPException

app = FastAPI(title="Students API")

students = [
    {"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
    {"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
]


@app.get("/students/stats")
def students_stats():
    total_age = 0
    for student in students:
        total_age = total_age + student["age"]
    return {"count": len(students), "average_age": round(total_age / len(students), 1)}


@app.get("/students/{student_id}")
def get_student(student_id: int):
    for student in students:
        if student["id"] == student_id:
            return student
    raise HTTPException(status_code=404, detail="student not found")

GET /students/stats → {"count": 2, "average_age": 27.5}.

את /students/stats מגדירים לפני /students/{student_id}. FastAPI בודק Routes לפי הסדר — אילו {student_id} היה ראשון, stats היה נחשב ל-id, ונכשל ב-422 כי הוא לא מספר.

סיכום

  • Type Hints (x: int) — FastAPI ממיר וּבודק לפיהם. קלט לא תקין מקבל 422 אוטומטית, והפונקציה לא רצה.
  • Path Parameter: "/students/{student_id}" + student_id: int, באותו שם בדיוק.
  • Query: כל פרמטר שלא בנתיב. x: int = 0 אופציונלי. x: str | None = None אופציונלי בלי ברירת מחדל. בלי =, חובה.
  • Body: Class שיורשת מ-BaseModel (Pydantic). פרמטר מהטיפוס הזה = JSON מהבקשה, אחרי ולידציה.
  • status_code=201 ליצירה. raise HTTPException(404, ...) ללא נמצא, ו-400 לכלל עסקי.
  • נתיבים קבועים לפני נתיבים עם פרמטר. ונתונים ב-RAM נמחקים בהפעלה מחדש, ולכן צריך Database.

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