Retour au cours

backend / fastapi

Modèles imbriqués

Leçon 41 exercice

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.

BesoinOutil 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 typesGeneric[T]
Payload qui peut prendre plusieurs formesUnion 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

python
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)
python
# 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",
}
python
# 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
python
# 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)
python
# 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

1 disponible
1

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.

Résoudre l’exercice →