# Students API — CRUD מלא: POST, GET, PUT ו-DELETE מול המסד<no value>

## מה נלמד?

משלימים את ה-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 או שירות אחר יכולים ליצור, לקרוא, לעדכן ולמחוק סטודנטים, והנתונים נשמרים במסד.

## הסבר מהבסיס

### מודל הקלט

```python
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):

```python
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`

```python
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).

## תחביר

```python
@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` המלא:**

```python
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 קטן). |
| **DELETE** | `db.delete_student` מחזירה `False` ‏←‏ 404. הצלחה ‏←‏ אין `return` ‏←‏ **204** בלי גוף. |
| `if not db.delete_student(...)` | קריאה **ובדיקה** בשורה אחת. המחיקה מתבצעת בכל מקרה, ו-`not` הופך את `False` ל"נכשל". |

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

## דוגמה נוספת

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

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

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

```text
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.**

```python
@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.**

```python
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`).

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## תרגול

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

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

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

{{< solution >}}
**main.py**

```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**, עוד לפני שהפונקציה רצה.
{{< /solution >}}

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

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

{{< solution >}}
**main.py** (בתוספת)

```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))
```

הבדיקות שלנו רצות **לפני** שנוגעים במסד: קלט פסול לא מגיע לשכבת הנתונים בכלל.
{{< /solution >}}

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

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

{{< solution >}}
**main.py** (בתוספת)

```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.
{{< /solution >}}

### תרגיל 4: DELETE — 204

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

{{< solution >}}
**main.py** (בתוספת)

```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**.
{{< /solution >}}

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

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

```text
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)
```

{{< solution >}}
| בקשה | קוד | מי |
|---|---|---|
| 1 | **422** | Pydantic — גיל אינו מספר |
| 2 | **400** | `validate_student` — כלל עסקי |
| 3 | **409** | תרגום של `ValueError` מ-`db` — כפילות במסד |
| 4 | **404** | `db.update_student` החזירה `None` |
| 5 | **204** | הצלחה בלי גוף |
{{< /solution >}}

<!-- exercises:end -->

## סיכום

- **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 מלא, כי כל שכבה עושה את שלה.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

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

- [FastAPI — Request Body](https://fastapi.tiangolo.com/tutorial/body/) — POST ו-PUT עם Body
- [FastAPI — Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/) — status_code=201
- [FastAPI — Body - Updates](https://fastapi.tiangolo.com/tutorial/body-updates/) — PUT — עדכון רשומה

<!-- docs:end -->
