FastAPI : Path Params, query, body et HTTPException
- Comprendre les Type Hints et comment FastAPI les utilise pour la conversion et la validation
- Définir un Path Parameter et des Query Parameters (obligatoires, avec une valeur par défaut et facultatifs)
- Définir un body avec un modèle Pydantic (BaseModel) et le recevoir dans un POST
- Renvoyer les bons codes — 201 à la création, 404 avec HTTPException — et comprendre le 422 automatique
Qu’allons-nous apprendre ?
Dans la leçon précédente, les fonctions ne recevaient aucune entrée. Maintenant, elles vont recevoir les trois types que nous avons appris :
| Entrée | Comment FastAPI la reconnaît |
|---|---|
| Path Parameter | Un nom entre accolades dans le chemin : "/students/{student_id}" |
| Query Parameter | Un paramètre de la fonction qui n’apparaît pas dans le chemin |
| Body | Un paramètre dont le type est un modèle Pydantic |
Et en plus : les Type Hints, status_code, HTTPException, et l’erreur 422 automatique.
Pourquoi en avons-nous besoin ?
Sans entrée, un serveur ne peut que renvoyer toujours la même chose. Avec une entrée, il peut renvoyer un étudiant précis, filtrer, et créer de nouveaux étudiants. Et un énorme bonus : FastAPI vérifie et traduit l’entrée pour nous. Un serveur de calculatrice écrit à la main plantait sur "hello". Ici, une mauvaise entrée recevra automatiquement un 422 avec une explication précise, et le serveur continuera de fonctionner.
Explication à partir des bases
Les Type Hints
Type Hint = une annotation dans le code qui indique le type attendu d’un paramètre :
def get_student(student_id: int):student_id: int = « ce paramètre est censé être un int ». En Python ordinaire, les Type Hints ne sont pas imposés : ce n’est que de la documentation, qui aide VS Code et les développeurs. Mais FastAPI les lit et les utilise :
- La conversion — la chaîne
"7"arrive de l’URL, et FastAPI la convertit en7, car nous avons demandé unint. - La validation — si
"abc"est arrivé, FastAPI n’appelle pas la fonction, mais renvoie un 422 avec une explication. - La documentation —
/docsaffiche le type de chaque paramètre.
Path Parameter
@app.get("/students/{student_id}")
def get_student(student_id: int):{student_id} dans le chemin = « cette partie change ». FastAPI la passe au paramètre qui porte exactement le même nom.
Query Parameters
Tout paramètre de la fonction qui n’apparaît pas dans le chemin (et qui n’est pas un modèle) = un Query Parameter.
def list_students(city: str | None = None, min_age: int = 0, limit: int = 10):| Définition | Signification |
|---|---|
min_age: int = 0 | Facultatif, avec une valeur par défaut de 0. |
limit: int (sans =) | Obligatoire. Sans lui → 422. |
city: str | None = None | Facultatif, sans valeur par défaut : « soit une chaîne, soit None ». None = « il n’a pas été envoyé, donc on ne filtre pas ». |
str | None se lit « str ou None ». C’est un Type Hint qui dit que deux types sont permis.
Le body avec Pydantic
Pydantic est une bibliothèque (installée avec FastAPI) qui définit une structure de données avec des types. On crée une classe qui hérite de BaseModel (l’héritage, le thème POO !) :
from pydantic import BaseModel
class StudentIn(BaseModel):
name: str
age: int
city: str = "Unknown"- Chaque ligne = un champ, avec un type. Un champ avec
=est facultatif, avec une valeur par défaut. - Il n’y a pas de
__init__!BaseModelle construit tout seul à partir des champs. - Quand le paramètre d’une fonction est de ce type, FastAPI comprend que c’est un body : il lit le JSON, vérifie que tous les champs existent et sont du bon type, et construit un objet. On accède aux champs avec un point :
student.name.
Un modèle Pydantic est un formulaire imprimé : « Nom (obligatoire, texte). Âge (obligatoire, un nombre). Ville (facultatif) ». L’employé (FastAPI) ne transmet pas pour traitement (à votre fonction) un formulaire auquel il manque un champ obligatoire, ou dans lequel on a écrit « vingt » dans le champ de l’âge. Il le rend immédiatement à celui qui l’a soumis (422) avec le champ problématique marqué. Votre fonction ne reçoit que des formulaires valides.
status_code et HTTPException
@app.post("/students", status_code=201)— le code de succès de cet endpoint est 201 Created, et non 200.raise HTTPException(status_code=404, detail="...")— arrête la fonction et renvoie un code d’erreur au client avec le JSON{"detail": "..."}. C’est unraiseordinaire (souvenez-vous du thème des exceptions !), et FastAPI l’attrape et le transforme en réponse HTTP.
Syntaxe
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 fieldsPremier exemple de code
Une API d’étudiants en mémoire (en RAM. Il n’y a pas encore de base de données, elle viendra dans le thème des bases de données). 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_studentLe lancement : uvicorn students_memory:app --reload (remarquez : le nom du fichier a changé !).
Analyse du code ligne par ligne
| Code | Explication |
|---|---|
students = [...] | Une « base de données » temporaire en RAM : une liste de dicts. Elle est effacée à chaque redémarrage (souvenez-vous de la RAM du thème sur l’ordinateur !). Dans le thème des bases de données, nous passerons à une vraie base de données. |
class StudentIn(BaseModel): | Le modèle du body : ce que le client envoie à la création. Il n’y a pas d’id dedans, car c’est le serveur qui fixe l’id. |
city: str = "Unknown" | Un champ facultatif dans le body. |
list_students(city: str | None = None, min_age: int = 0, limit: int = 10) | Trois Query Parameters, tous facultatifs. /students?city=Haifa&min_age=23. |
if city is not None and ...: continue | Filtrer seulement si city a été envoyé. continue passe à l’étudiant suivant. |
return result[:limit] | Le Slicing : au plus limit résultats. |
@app.get("/students/{student_id}") + student_id: int | Un Path Parameter, avec conversion en int. |
raise HTTPException(status_code=404, ...) | Introuvable, donc un 404 avec un message. |
@app.post(..., status_code=201) | La création renvoie 201. |
create_student(student: StudentIn) | Le type est un modèle, c’est donc un body. FastAPI le décode et le vérifie. |
student.name, student.age | L’accès aux champs d’un objet (le thème POO). |
max(s["id"] for s in students) + 1 | Un nouvel identifiant : le plus grand + 1. |
Un autre exemple
Un test complet de tous les types d’entrée, et les vraies réponses. (Vous pouvez aussi tester dans /docs, ou avec curl.)
| Requête | Code | Réponse |
|---|---|---|
GET /students | 200 | Les trois |
GET /students?city=Haifa | 200 | Dana et Noa |
GET /students?min_age=23&limit=1 | 200 | [{"id": 1, "name": "Dana", ...}] |
GET /students/2 | 200 | {"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"} |
GET /students/42 | 404 | {"detail": "student 42 not found"} |
GET /students/abc | 422 | Voir ci-dessous |
GET /students?min_age=old | 422 | Voir ci-dessous |
POST /students + {"name": "Tom", "age": 27} | 201 | {"id": 4, "name": "Tom", "age": 27, "city": "Unknown"} |
POST /students + {"name": "Rina", "age": "twenty"} | 422 | Voir ci-dessous |
L’erreur 422 — à quoi elle ressemble (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"
}
]
}Lire un 422 :
loc(Location) — où est le problème :["path", "student_id"]. Dans la query, ce serait["query", "min_age"], et dans le body["body", "age"].msg— quel est le problème.input— ce qui a réellement été reçu.- Notre fonction ne s’est pas exécutée du tout. FastAPI a arrêté la requête avant. Pas besoin de
try/exceptautour deint().
Et après le POST de Tom : GET /students?city=Unknown renvoie Tom, car la ville a reçu la valeur par défaut. Mais arrêtez le serveur et relancez-le : Tom a disparu. Les données étaient en RAM. C’est exactement la raison d’être d’une base de données (c’est pour bientôt !).
Erreur courante
Erreur 1 : un nom qui ne correspond pas entre le chemin et le paramètre.
@app.get("/students/{student_id}")
def get_student(id: int):FastAPI ne trouve pas de paramètre nommé student_id, et traite id comme un Query Parameter obligatoire. Chaque requête vers /students/7 renverra un 422 ("loc": ["query", "id"], manquant). Le nom entre accolades doit être identique au nom du paramètre.
Erreur 2 : un Path Parameter sans Type Hint.
def get_student(student_id):
for student in students:
if student["id"] == student_id:Sans : int, le paramètre arrive sous forme de chaîne "2", et 2 == "2" vaut False. Le résultat : toujours 404, sans aucune erreur. Une Logical Error silencieuse.
Erreur 3 : l’ordre des routes avec un chemin fixe et un chemin avec paramètre.
@app.get("/students/{student_id}")
def get_student(student_id: int): ...
@app.get("/students/top")
def top_students(): ...GET /students/top → 422 ! FastAPI vérifie les routes dans l’ordre, et /students/{student_id} « attrape » top comme student_id, et échoue à le convertir en int. Les chemins fixes avant les chemins avec paramètres.
Erreur 4 : return au lieu de raise pour une HTTPException.
return HTTPException(status_code=404, detail="not found")Renvoie 200, avec un objet étrange dans le JSON. Une erreur se lance (raise), elle ne se renvoie pas.
Erreur 5 : inclure id dans le modèle du body. Si StudentIn contient id: int, le client doit envoyer un id, et peut envoyer un id qui existe déjà. C’est le serveur qui fixe l’identifiant. Le modèle du body ne contient que ce que le client est censé envoyer.
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: Un Path Parameter avec un Type Hint
Écrivez une API avec une liste d’étudiants en mémoire (des dictionnaires avec id, name, age, city), et une route GET /students/{student_id} qui renvoie l’étudiant, ou 404 avec un detail valant "student not found".
- Que se passera-t-il avec la requête
GET /students/abc?
Afficher la solution
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 avec Avi.GET /students/9→ 404{"detail": "student not found"}.GET /students/abc→ 422 : le Type Hintstudent_id: intfait que FastAPI vérifie et convertit tout seul. La fonction ne s’exécute pas du tout.
Exercice 2: Les Query Parameters
Ajoutez une route GET /students qui reçoit un Query Parameter facultatif city (valeur par défaut None), et ne renvoie que les étudiants de cette ville — ou tous si aucune ville n’a été envoyée.
Afficher la solution
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 resultGET /students→ les deux étudiants.GET /students?city=Haifa→ seulement Dana.
Un paramètre de la fonction qui ne fait pas partie du chemin devient automatiquement un Query Parameter. Une valeur par défaut le rend facultatif.
Exercice 3: Un POST avec un body
Ajoutez un modèle Pydantic nommé StudentIn (name, age, city) et une route POST /students qui renvoie 201, donne à l’étudiant un nouvel id et l’ajoute à la liste. Un âge inférieur à 16 → 400.
Afficher la solution
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_studentPOST /studentsavec{"name": "Noa", "age": 22, "city": "Eilat"}→ 201 avecid: 2.- L’âge 12 → 400. Un corps sans
age→ 422 — Pydantic rejette une mauvaise structure avant que la fonction ne s’exécute.
Exercice 4: 422 face à 400
- Quelle est la différence entre 422 et 400 dans FastAPI ? Qui renvoie chacun ?
- Pour chaque requête — quel code reviendra ?
POST /students {"name": "Noa", "age": "abc", "city": "Eilat"}
POST /students {"name": "Noa", "age": 10, "city": "Eilat"}
POST /students {"name": "Noa", "city": "Eilat"}Afficher la solution
- 422 — FastAPI et Pydantic le renvoient automatiquement quand la structure ou le type est faux. 400 — notre code le renvoie quand la valeur est valide du point de vue du type, mais ne respecte pas une règle métier.
- Les réponses :
"age": "abc"→ 422 (l’âge n’est pas un nombre)."age": 10→ 400 (un nombre valide, mais en dessous de l’âge minimum — notre règle).- Sans
age→ 422 (un champ obligatoire manque).
Exercice 5: Des statistiques
Ajoutez une route GET /students/stats qui renvoie le nombre d’étudiants et l’âge moyen (arrondi à un chiffre). Où faut-il la définir par rapport à GET /students/{student_id}, et pourquoi ?
Afficher la solution
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}.
Définissez /students/stats avant /students/{student_id}. FastAPI vérifie les routes dans l’ordre — si {student_id} venait en premier, stats serait traité comme un id, et échouerait avec un 422 parce que ce n’est pas un nombre.
Résumé
- Les Type Hints (
x: int) — FastAPI convertit et vérifie selon eux. Une entrée invalide reçoit automatiquement un 422, et la fonction ne s’exécute pas. - Path Parameter :
"/students/{student_id}"+student_id: int, avec exactement le même nom. - Query : tout paramètre qui n’est pas dans le chemin.
x: int = 0est facultatif.x: str | None = Noneest facultatif sans valeur par défaut. Sans=, obligatoire. - Body : une classe qui hérite de
BaseModel(Pydantic). Un paramètre de ce type = le JSON de la requête, après validation. status_code=201pour la création.raise HTTPException(404, ...)pour « introuvable », et 400 pour une règle métier.- Les chemins fixes avant les chemins avec paramètre. Et les données en RAM sont effacées au redémarrage, c’est pourquoi il faut une base de données.