FastAPI : Path Params, query, body et HTTPException

Objectifs de la leçon
  • 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éeComment FastAPI la reconnaît
Path ParameterUn nom entre accolades dans le chemin : "/students/{student_id}"
Query ParameterUn paramètre de la fonction qui n’apparaît pas dans le chemin
BodyUn 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 :

  1. La conversion — la chaîne "7" arrive de l’URL, et FastAPI la convertit en 7, car nous avons demandé un int.
  2. La validation — si "abc" est arrivé, FastAPI n’appelle pas la fonction, mais renvoie un 422 avec une explication.
  3. La documentation — /docs affiche 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éfinitionSignification
min_age: int = 0Facultatif, avec une valeur par défaut de 0.
limit: int (sans =)Obligatoire. Sans lui → 422.
city: str | None = NoneFacultatif, 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__ ! BaseModel le 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.
Analogie : un formulaire avec des champs obligatoires

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 un raise ordinaire (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 fields

Premier 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_student

Le lancement : uvicorn students_memory:app --reload (remarquez : le nom du fichier a changé !).

Analyse du code ligne par ligne

CodeExplication
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 ...: continueFiltrer 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: intUn 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.ageL’accès aux champs d’un objet (le thème POO).
max(s["id"] for s in students) + 1Un 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êteCodeRéponse
GET /students200Les trois
GET /students?city=Haifa200Dana et Noa
GET /students?min_age=23&limit=1200[{"id": 1, "name": "Dana", ...}]
GET /students/2200{"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"}
GET /students/42404{"detail": "student 42 not found"}
GET /students/abc422Voir ci-dessous
GET /students?min_age=old422Voir ci-dessous
POST /students + {"name": "Tom", "age": 27}201{"id": 4, "name": "Tom", "age": 27, "city": "Unknown"}
POST /students + {"name": "Rina", "age": "twenty"}422Voir 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/except autour de int().

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

  1. 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 Hint student_id: int fait 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 result
  • GET /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_student
  • POST /students avec {"name": "Noa", "age": 22, "city": "Eilat"} → 201 avec id: 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

  1. Quelle est la différence entre 422 et 400 dans FastAPI ? Qui renvoie chacun ?
  2. 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
  1. 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.
  2. 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 = 0 est facultatif. x: str | None = None est 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=201 pour 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.

Documentation officielle