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

Objectifs de la leçon
  • Ajouter POST, PUT et DELETE reliés aux fonctions de db.py
  • Effectuer une validation métier dans une fonction partagée, et renvoyer 400
  • Traduire le contrat de db.py en codes — 201, 204, 404 et 409
  • Lancer le serveur complet et lire correctement chaque réponse

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

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

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 :

CoucheVérifieCode
Pydantic (FastAPI)La structure et les types422
validate_student (main.py)Les règles métier400
Les contraintes de la base (db.py)L’unicité, l’obligation409 (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

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

@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 :

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

PartieExplication
validate_student(student)Une seule validation pour deux endpoints. raise HTTPException(400) arrête la requête avant de toucher la base.
POSTValidation → db.create_student → renvoie l’étudiant depuis la base (avec un id) et 201. Une ValueError (l’e-mail existe) → 409.
PUTValidation → db.update_student. Deux échecs différents : une ValueError → 409, et None → 404.
updated = ... dans le try, la vérification de None à l’extérieurLe try n’enveloppe que ce qui peut lancer une ValueError (un petit try).
DELETEdb.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êteBodyCodeRéponse
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(vide)
13DELETE /students/1—404{"detail": "student not found"}
14GET /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 :

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.

@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.

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

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.

Afficher la solution

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))
  • 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.

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.

Afficher la solution

main.py (en plus)

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.

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.

Afficher la solution

main.py (en plus)

@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.

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 ?

Afficher la solution

main.py (en plus)

@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.

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 ?

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)
Afficher la solution
RequêteCodeQui
1422Pydantic — l’âge n’est pas un nombre
2400validate_student — une règle métier
3409La traduction d’une ValueError venant de db — un doublon dans la base
4404db.update_student a renvoyé None
5204Succès sans corps

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.

Documentation officielle