# Students API — Full CRUD: POST, GET, PUT and DELETE on the Database<no value>

## What will we learn?

We complete the API according to the mapping table:

- **`POST /students`** → INSERT → **201**.
- **`PUT /students/{id}`** → UPDATE → 200 / **404** / **409**.
- **`DELETE /students/{id}`** → DELETE → **204** / 404.
- **An input model** (Pydantic) and shared **business validation** (400).

## Why do we need this?

This is **the short project that sums up Server + Database + CRUD**, as the syllabus defines. At the end of the lesson there is a real API: an app, a Frontend or another service can create, read, update and delete students, and the data is saved in the database.

## Explanation from the basics

### The input model

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

**`StudentIn`** describes what the Client **sends** in POST and in PUT. **There is no `id` in it**, because the id is given by the database (POST) or by the Path (PUT). The name `...In` is a common convention: "the model of what comes in".

FastAPI validates **the structure and the types** (a missing field, text instead of a number → **422**, automatically).

### Business validation — 400

**The rules** (age 16–120, a name of at least 2 characters, an email with `@`) are **ours**. Check them in **one** function, which POST and PUT call (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")
```

**Three layers of checks, each with its own role:**

| Layer | Checks | Code |
|---|---|---|
| Pydantic (FastAPI) | Structure and types | 422 |
| `validate_student` (main.py) | Business rules | 400 |
| The database's constraints (db.py) | Uniqueness, required | 409 (through a `ValueError`) |

### 204 No Content

On a successful DELETE **there is nothing to return**: the student was deleted. `status_code=204` says "I succeeded, without a body". The function simply returns nothing (`None`), and FastAPI sends an empty response.

### Translating a `ValueError`

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

`str(error)` = the message that `db.py` wrote ("a student with email ... already exists"). **The message passes through two layers**: from the database (`IntegrityError`), through `db.py` (`ValueError`), to the Client (409 + detail).

## Syntax

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

In PUT there is **both a Path Parameter and a Body**. FastAPI tells them apart: `student_id` in the path, and `student` (a model) in the Body.

## First code example

**The complete `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")
```

**Running:** `uvicorn main:app --reload`, from the project folder, with the venv active.

## Breaking down the code line by line

| Part | Explanation |
|---|---|
| `validate_student(student)` | **One** validation for two Endpoints. `raise HTTPException(400)` stops the request **before** the database is touched. |
| **POST** | Validation → `db.create_student` → returns the student from the database (with an id) and **201**. A `ValueError` (the email exists) → **409**. |
| **PUT** | Validation → `db.update_student`. **Two** different failures: a `ValueError` → 409, and `None` → 404. |
| `updated = ...` inside the `try`, the check for `None` outside it | The `try` wraps **only** what may throw a `ValueError` (a small try). |
| **DELETE** | `db.delete_student` returns `False` → 404. Success → no `return` → **204** without a body. |
| `if not db.delete_student(...)` | A call **and a check** in one line. The delete happens in any case, and `not` turns `False` into "failed". |

**In total:** a file of about 60 lines, which contains a full API. Every Route is short, because all the heavy work (SQL, constraints) is in `db.py`, and all the conversions (JSON, types) are in FastAPI.

## Another example

**All the server's real responses** — a sequence of requests on an empty database (tested with the code above):

| # | Request | Body | Code | Response |
|---|---|---|---|---|
| 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** | (empty) |
| 13 | `DELETE /students/1` | — | **404** | `{"detail": "student not found"}` |
| 14 | `GET /students` | — | **200** | `[{"id": 2, "name": "Avi Levi", "age": 32, "email": "avi@mail.com"}]` |

**And the server's log** (in uvicorn's Terminal) shows a line for every request, for example:

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

**Reading the table correctly:** each of the rows has a reason **in a different layer**:

- **422** (rows 5, 6, 9) — FastAPI and Pydantic. Our code did not run.
- **400** (row 4) — `validate_student`.
- **409** (row 3) — the database (`UNIQUE`), through `db.py`.
- **404** (rows 8, 11, 13) — `db.py` returned `None`/`False`.

## Common mistake

**Mistake 1: forgetting `status_code=201` in POST, or `204` in DELETE.** Everything "works", but with 200. The Client (and automated tests) expect the right codes.

**Mistake 2: returning content from a DELETE with 204.**

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

According to the HTTP standard, a 204 **must not** have a body. Different versions of FastAPI handle this differently (an error, or a body that is thrown away). Either 204 without a `return`, or 200 with a message. Not both.

**Mistake 3: forgetting the check for `None` in PUT.** `return db.update_student(...)` returns **200 with `null`** for a student that does not exist.

**Mistake 4: `except Exception` in a Route.**

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

A real bug (for example a missing table) will be returned as "409 Conflict", and mislead everyone. **Catch only `ValueError`**, and everything else will arrive as a **500**, so that we see it and fix it.

**Mistake 5: duplicated and different validation.** An age check in POST "over 16", and in PUT "over 18". **One function** (`validate_student`).

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## Practice

The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.

### Exercise 1: POST — creating

Add to `main.py` a model `StudentIn` (`name`, `age`, `email`) and a `POST /students` that returns 201. A duplicate email (a `ValueError` from `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))
```

- A successful creation → **201** with the student (including the `id`).
- The same email again → **409** with `"a student with email ... already exists"`.
- A body without `email` → **422**, even before the function runs.
{{< /solution >}}

### Exercise 2: Validation rules

Write `validate_student(student)` that throws an `HTTPException` with 400 if: the name is shorter than 2 characters, the age is outside 16 to 120, or there is no `@` in the email. Run it in POST before saving.

{{< solution >}}
**main.py** (in addition)

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

Our checks run **before** the database is touched: invalid input does not reach the data layer at all.
{{< /solution >}}

### Exercise 3: PUT — updating

Add `PUT /students/{student_id}`: validation (400), a student that does not exist (404), an email taken by another student (409), and otherwise 200 with the updated student.

{{< solution >}}
**main.py** (in addition)

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

Every result of `db.update_student` is translated into a different code: a dictionary → 200, `None` → 404, a `ValueError` → 409.
{{< /solution >}}

### Exercise 4: DELETE — 204

Add `DELETE /students/{student_id}` that returns **204** when the delete succeeded, and 404 when there is no such student. Why don't you return a body with 204?

{{< solution >}}
**main.py** (in addition)

```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** says exactly "I succeeded, and there is nothing to return". That is why the function returns nothing. A second delete of the same id → **404**.
{{< /solution >}}

### Exercise 5: Who decides on which code?

For each request — which code will come back, and **who** returns it: Pydantic, our code (`validate_student`), or the translation from `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 >}}
| Request | Code | Who |
|---|---|---|
| 1 | **422** | Pydantic — the age is not a number |
| 2 | **400** | `validate_student` — a business rule |
| 3 | **409** | Translating a `ValueError` from `db` — a duplicate in the database |
| 4 | **404** | `db.update_student` returned `None` |
| 5 | **204** | Success without a body |
{{< /solution >}}

<!-- exercises:end -->

## Summary

- **POST** → `db.create_student` → **201** + the student. A `ValueError` → **409**.
- **PUT** (Path + Body) → `db.update_student` → 200. `None` → **404**. A `ValueError` → **409**.
- **DELETE** → `db.delete_student` → **204** without a body. `False` → **404**.
- Three layers of checks: **422** (Pydantic, structure), **400** (`validate_student`, rules), **409** (the database, uniqueness).
- Validation in **one** function for POST and PUT. Catch only `ValueError`, and everything else stays a 500.
- A `main.py` of about 60 lines = a full API, because every layer does its own job.

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

## Official documentation

- [FastAPI — Request Body](https://fastapi.tiangolo.com/tutorial/body/)
- [FastAPI — Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/)
- [FastAPI — Body - Updates](https://fastapi.tiangolo.com/tutorial/body-updates/)

<!-- docs:end -->
