backend / fastapi
Modèles imbriqués
Explication
Ce que vous allez apprendre
- Imbriquer des modèles Pydantic les uns dans les autres pour représenter une structure réaliste
- Construire un modèle récursif pour représenter une arborescence (catégories, commentaires)
- Créer un modèle générique réutilisable pour la pagination avec
Generic[T] - Utiliser une union discriminée pour valider un payload qui peut prendre plusieurs formes
- Éviter le piège d'un modèle récursif non résolu correctement
Dans quel contexte ?
L'endpoint POST /orders de app/routers/orders.py doit accepter une commande complète en une seule requête : adresse de livraison, liste d'articles, chacun avec sa quantité et son prix. Représenter tout ça avec un simple dict ne garantirait aucune structure ni aucun type ; un modèle Pydantic Order avec Address et list[OrderItem] imbriqués valide chaque niveau automatiquement, et renvoie une erreur 422 précise si l'adresse manque son code postal.
Une image pour commencer
Un modèle Pydantic n'est pas limité à des champs "plats" comme str, int ou bool. Il peut contenir d'autres modèles Pydantic, comme des poupées russes qui s'emboîtent.
C'est exactement comme un objet JSON réel, qui contient souvent des sous-objets. Une commande a une adresse de livraison, l'adresse a une ville, et ainsi de suite.
Sans cette imbrication, on serait tenté de représenter une adresse comme un simple dictionnaire. Mais un dictionnaire n'offre aucune garantie sur ses clés ni leurs types.
En la déclarant comme un modèle Pydantic à part entière, chaque niveau bénéficie de la même validation automatique. Un champ profondément imbriqué manquant, comme un code postal absent, est détecté et rapporté avec précision au client.
Une fois cette imbrication simple maîtrisée, un cas plus avancé se présente : un modèle qui se référence lui-même. Utile pour représenter une arborescence, comme des catégories avec sous-catégories.
Ce modèle récursif nécessite une "forward reference", le nom du modèle entre guillemets. Au moment où Python lit la classe, elle n'existe pas encore complètement, d'où cette syntaxe particulière.
Il existe un deuxième cas avancé utile pour éviter la duplication : les génériques. Generic[T] permet d'écrire UNE seule structure de pagination réutilisable pour n'importe quel type de donnée.
Plutôt que de dupliquer une classe de pagination par type de ressource, une seule suffit. Le type T s'adapte automatiquement à ce qu'on lui donne.
Il reste un dernier cas à connaître : un payload qui peut prendre plusieurs formes différentes. Un paiement par carte OU par PayPal, par exemple.
Une union discriminée gère précisément ce cas. Elle laisse Pydantic choisir automatiquement le bon sous-modèle selon un champ discriminant.
| Besoin | Outil Pydantic |
|---|---|
| Structure imbriquée simple (adresse dans une commande) | Modèle imbriqué classique |
| Arborescence (catégories, commentaires avec réponses) | Modèle récursif + model_rebuild() |
| Réutiliser une même structure pour différents types | Generic[T] |
| Payload qui peut prendre plusieurs formes | Union discriminée (Field(discriminator=...)) |
Piège fréquent
Oublier model_rebuild() après un modèle récursif fait que Python n'arrive pas à résoudre seul une référence à une classe pas encore complètement définie au moment où elle est lue — l'erreur ne se manifeste souvent qu'à l'utilisation du modèle, pas à sa définition.
Commandes & code
Modèles imbriqués
from pydantic import BaseModel
from datetime import datetime
class Address(BaseModel):
street: str
city: str
zip_code: str
country: str = "FR"
class OrderItem(BaseModel):
product_id: int
quantity: int
unit_price: float
@property
def subtotal(self) -> float:
return self.quantity * self.unit_price
class Order(BaseModel):
id: int
customer_email: str
shipping_address: Address # modèle imbriqué
billing_address: Address | None = None
items: list[OrderItem] # liste de modèles imbriqués
created_at: datetime
@property
def total(self) -> float:
return sum(item.subtotal for item in self.items)# Payload JSON attendu correspondant au modèle ci-dessus
example_payload = {
"id": 1,
"customer_email": "client@example.com",
"shipping_address": {"street": "12 rue de Paris", "city": "Lyon", "zip_code": "69000"},
"items": [
{"product_id": 1, "quantity": 2, "unit_price": 19.99},
{"product_id": 5, "quantity": 1, "unit_price": 49.90},
],
"created_at": "2026-01-15T10:30:00",
}# Modèles récursifs — arborescence (catégories imbriquées, commentaires avec réponses)
class Category(BaseModel):
id: int
name: str
children: list["Category"] = [] # référence à soi-même (forward reference)
Category.model_rebuild() # nécessaire pour résoudre la référence récursive# Génériques Pydantic — réponse paginée réutilisable pour n'importe quel type de donnée
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
@property
def total_pages(self) -> int:
return (self.total + self.page_size - 1) // self.page_size
# Utilisation dans un endpoint
from fastapi import FastAPI
app = FastAPI()
class ProductOut(BaseModel):
id: int
name: str
@app.get("/products", response_model=Page[ProductOut])
def list_products() -> Page[ProductOut]:
return Page(items=[ProductOut(id=1, name="Clavier")], total=1, page=1, page_size=20)# Union discriminée — plusieurs formes de payload possibles, distinguées par un champ
from typing import Literal, Union
from pydantic import Field
class CreditCardPayment(BaseModel):
method: Literal["credit_card"]
card_number: str
expiry: str
class PaypalPayment(BaseModel):
method: Literal["paypal"]
paypal_email: str
class CheckoutRequest(BaseModel):
order_id: int
payment: Union[CreditCardPayment, PaypalPayment] = Field(discriminator="method")
# Pydantic choisit automatiquement le bon sous-modèle selon la valeur de "method"Résumé
- Les modèles Pydantic s'imbriquent naturellement : listes, objets nichés, structures récursives.
Generic[T]permet de créer un modèle de pagination (ou enveloppe de réponse) réutilisable pour tout type.- Une union discriminée (
Field(discriminator=...)) valide proprement un payload polymorphe (plusieurs formes possibles). - Un modèle récursif nécessite
model_rebuild()pour résoudre la référence à lui-même.
Exercices pratiques
Mission : l'arborescence de catégories qui plante à l'usage
Objectif : Corriger un modèle Category récursif mal construit et concevoir un modèle de pagination générique pour l'API de commandes.
Contexte
Sur app/routers/orders.py, un modèle Category avec des sous-catégories (children: list["Category"]) fonctionne bien en développement mais plante avec une erreur cryptique dès qu'un autre développeur importe le module dans un ordre différent. Par ailleurs, l'équipe veut paginer aussi bien les produits que les commandes sans dupliquer de code.