# L'API des étudiants — CRUD complet : POST, GET, PUT et DELETE sur la base de données<no value>

## Qu'allons-nous apprendre ?

Nous complétons l'API selon le tableau de correspondance :

- **`POST /students`** → INSERT → **201**.
- **`PUT /students/{id}`** → UPDATE → 200 / **404** / **409**.
- **`DELETE /students/{id}`** → DELETE → **204** / 404.
- **Un modèle d'entrée** (Pydantic) et une **validation métier** partagée (400).

## Pourquoi en avons-nous besoin ?

C'est **le court projet qui résume serveur + base de données + CRUD**, comme le définit le programme. À la fin de la leçon, il y a une vraie API : une application, un frontend ou un autre service peut créer, lire, mettre à jour et supprimer des étudiants, et les données sont enregistrées dans la base.

## Explication à partir des bases

### Le modèle d'entrée

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

**`StudentIn`** décrit ce que le client **envoie** dans un POST et dans un PUT. **Il n'y a pas d'`id` dedans**, car l'id est donné par la base (POST) ou par le chemin (PUT). Le nom `...In` est une convention courante : « le modèle de ce qui entre ».

FastAPI valide **la structure et les types** (un champ manquant, du texte au lieu d'un nombre → **422**, automatiquement).

### La validation métier — 400

**Les règles** (âge 16–120, un nom d'au moins 2 caractères, un e-mail avec `@`) sont **les nôtres**. Vérifiez-les dans **une seule** fonction, que POST et PUT appellent (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")
```

**Trois couches de vérifications, chacune avec son rôle :**

| Couche | Vérifie | Code |
|---|---|---|
| Pydantic (FastAPI) | La structure et les types | 422 |
| `validate_student` (main.py) | Les règles métier | 400 |
| Les contraintes de la base (db.py) | L'unicité, l'obligation | 409 (via une `ValueError`) |

### 204 No Content

Pour un DELETE réussi, **il n'y a rien à renvoyer** : l'étudiant a été supprimé. `status_code=204` dit « j'ai réussi, sans corps ». La fonction ne renvoie simplement rien (`None`), et FastAPI envoie une réponse vide.

### Traduire une `ValueError`

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

`str(error)` = le message qu'a écrit `db.py` (« a student with email ... already exists »). **Le message traverse deux couches** : de la base (`IntegrityError`), via `db.py` (`ValueError`), jusqu'au client (409 + detail).

## Syntaxe

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

Dans PUT, il y a **à la fois un Path Parameter et un body**. FastAPI les distingue : `student_id` dans le chemin, et `student` (un modèle) dans le body.

## Premier exemple de code

**Le `main.py` complet :**

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

**Le lancement :** `uvicorn main:app --reload`, depuis le dossier du projet, avec le venv actif.

## Analyse du code ligne par ligne

| Partie | Explication |
|---|---|
| `validate_student(student)` | **Une seule** validation pour deux endpoints. `raise HTTPException(400)` arrête la requête **avant** de toucher la base. |
| **POST** | Validation → `db.create_student` → renvoie l'étudiant depuis la base (avec un id) et **201**. Une `ValueError` (l'e-mail existe) → **409**. |
| **PUT** | Validation → `db.update_student`. **Deux** échecs différents : une `ValueError` → 409, et `None` → 404. |
| `updated = ...` dans le `try`, la vérification de `None` à l'extérieur | Le `try` n'enveloppe **que** ce qui peut lancer une `ValueError` (un petit try). |
| **DELETE** | `db.delete_student` renvoie `False` → 404. Succès → pas de `return` → **204** sans corps. |
| `if not db.delete_student(...)` | Un appel **et une vérification** en une ligne. La suppression a lieu dans tous les cas, et `not` transforme `False` en « échec ». |

**Au total :** un fichier d'environ 60 lignes, qui contient une API complète. Chaque route est courte, car tout le travail lourd (SQL, contraintes) est dans `db.py`, et toutes les conversions (JSON, types) sont dans FastAPI.

## Un autre exemple

**Toutes les vraies réponses du serveur** — une suite de requêtes sur une base vide (testée avec le code ci-dessus) :

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

**Et le journal du serveur** (dans le terminal d'uvicorn) affiche une ligne pour chaque requête, par exemple :

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

**Bien lire le tableau :** chacune des lignes a une raison **dans une couche différente** :

- **422** (lignes 5, 6, 9) — FastAPI et Pydantic. Notre code ne s'est pas exécuté.
- **400** (ligne 4) — `validate_student`.
- **409** (ligne 3) — la base (`UNIQUE`), via `db.py`.
- **404** (lignes 8, 11, 13) — `db.py` a renvoyé `None`/`False`.

## Erreur courante

**Erreur 1 : oublier `status_code=201` dans POST, ou `204` dans DELETE.** Tout « fonctionne », mais avec 200. Le client (et les tests automatisés) attend les bons codes.

**Erreur 2 : renvoyer du contenu depuis un DELETE avec 204.**

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

Selon la norme HTTP, un 204 **ne doit pas** avoir de corps. Différentes versions de FastAPI gèrent cela différemment (une erreur, ou un corps jeté). Soit 204 sans `return`, soit 200 avec un message. Pas les deux.

**Erreur 3 : oublier la vérification de `None` dans PUT.** `return db.update_student(...)` renvoie **200 avec `null`** pour un étudiant qui n'existe pas.

**Erreur 4 : `except Exception` dans une route.**

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

Un vrai bug (par exemple une table manquante) sera renvoyé comme « 409 Conflict », et induira tout le monde en erreur. **N'attrapez que `ValueError`**, et tout le reste arrivera en **500**, pour que nous le voyions et le corrigions.

**Erreur 5 : une validation dupliquée et différente.** Une vérification de l'âge dans POST « plus de 16 », et dans PUT « plus de 18 ». **Une seule fonction** (`validate_student`).

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

Les exercices n'utilisent que ce qui a été vu dans cette leçon et les précédentes. Essayez d'abord seul, et n'ouvrez la solution qu'ensuite.

### Exercice 1: POST — créer

Ajoutez à `main.py` un modèle `StudentIn` (`name`, `age`, `email`) et un `POST /students` qui renvoie 201. Un e-mail en double (une `ValueError` de `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))
```

- Une création réussie → **201** avec l'étudiant (y compris l'`id`).
- Le même e-mail à nouveau → **409** avec `"a student with email ... already exists"`.
- Un corps sans `email` → **422**, avant même que la fonction ne s'exécute.
{{< /solution >}}

### Exercice 2: Les règles de validation

Écrivez `validate_student(student)` qui lance une `HTTPException` avec 400 si : le nom fait moins de 2 caractères, l'âge est en dehors de 16 à 120, ou il n'y a pas de `@` dans l'e-mail. Exécutez-la dans POST avant d'enregistrer.

{{< solution >}}
**main.py** (en plus)

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

Nos vérifications s'exécutent **avant** de toucher la base : une entrée invalide n'atteint même pas la couche de données.
{{< /solution >}}

### Exercice 3: PUT — mettre à jour

Ajoutez `PUT /students/{student_id}` : validation (400), un étudiant qui n'existe pas (404), un e-mail pris par un autre étudiant (409), et sinon 200 avec l'étudiant mis à jour.

{{< solution >}}
**main.py** (en plus)

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

Chaque résultat de `db.update_student` est traduit en un code différent : un dictionnaire → 200, `None` → 404, une `ValueError` → 409.
{{< /solution >}}

### Exercice 4: DELETE — 204

Ajoutez `DELETE /students/{student_id}` qui renvoie **204** quand la suppression a réussi, et 404 quand il n'y a pas de tel étudiant. Pourquoi ne renvoie-t-on pas de corps avec 204 ?

{{< solution >}}
**main.py** (en plus)

```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** dit exactement « j'ai réussi, et il n'y a rien à renvoyer ». C'est pourquoi la fonction ne renvoie rien. Une seconde suppression du même id → **404**.
{{< /solution >}}

### Exercice 5: Qui décide de quel code ?

Pour chaque requête — quel code reviendra, et **qui** le renvoie : Pydantic, notre code (`validate_student`), ou la traduction depuis `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 >}}
| Requête | Code | Qui |
|---|---|---|
| 1 | **422** | Pydantic — l'âge n'est pas un nombre |
| 2 | **400** | `validate_student` — une règle métier |
| 3 | **409** | La traduction d'une `ValueError` venant de `db` — un doublon dans la base |
| 4 | **404** | `db.update_student` a renvoyé `None` |
| 5 | **204** | Succès sans corps |
{{< /solution >}}

<!-- exercises:end -->

## Résumé

- **POST** → `db.create_student` → **201** + l'étudiant. Une `ValueError` → **409**.
- **PUT** (chemin + body) → `db.update_student` → 200. `None` → **404**. Une `ValueError` → **409**.
- **DELETE** → `db.delete_student` → **204** sans corps. `False` → **404**.
- Trois couches de vérifications : **422** (Pydantic, la structure), **400** (`validate_student`, les règles), **409** (la base, l'unicité).
- La validation dans **une seule** fonction pour POST et PUT. N'attrapez que `ValueError`, et tout le reste reste un 500.
- Un `main.py` d'environ 60 lignes = une API complète, car chaque couche fait son propre travail.

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

## Documentation officielle

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