FastAPI : construire une API REST avec validation

1. Vue d’ensemble

Lorsque vous écrivez une API REST, la majorité du code « défensif » réside dans la vérification des entrées : un champ est-il obligatoire, un nombre est-il négatif, un email est-il valide. FastAPI confie cette tâche à Pydantic. Vous n’avez qu’à déclarer les types de données et les contraintes, le framework validera automatiquement les requêtes et retournera une erreur 422 structurée.

Cet article construit une API de gestion de commandes (Order) de zéro. Après avoir lu cet article, vous saurez comment contraindre le body, le path et la query. Vous saurez aussi écrire des validateurs personnalisés, filtrer les données retournées, modifier le format des erreurs et tester le tout avec pytest.

2. Installation

Vous avez besoin de Python 3.10 ou supérieur. Créez un virtualenv puis installez FastAPI avec le groupe de dépendances standard :

python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" pytest

Le paquet fastapi[standard] inclut déjà tout ce dont cet article a besoin :

  • uvicorn pour exécuter le serveur.
  • fastapi-cli fournit la commande fastapi dev.
  • httpx utilisé pour TestClient.
  • email-validator utilisé pour le type EmailStr.

3. Premier endpoint avec un modèle Pydantic

Créez le fichier main.py :

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="Order API")


class OrderCreate(BaseModel):
    customer_email: str
    product_sku: str
    quantity: int


@app.post("/orders", status_code=201)
def create_order(order: OrderCreate) -> OrderCreate:
    return order

Exécutez le serveur en mode dev, avec rechargement automatique :

fastapi dev main.py

Ici, il n’y a aucune ligne if. Le paramètre order: OrderCreate indique à FastAPI trois choses :

  • Lire le JSON du corps de la requête.
  • Parser ce JSON en tant qu’OrderCreate.
  • Retourner 422 si les données ne correspondent pas au type.

Essayez d’envoyer quantity comme une chaîne de caractères, pas un nombre :

curl -X POST localhost:8000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":"abc"}'
{"detail":[{"type":"int_parsing","loc":["body","quantity"],
"msg":"Input should be a valid integer, unable to parse string as an integer",
"input":"abc"}]}

Le champ loc indique l’emplacement exact de l’erreur : dans body, au champ quantity. De plus, la documentation OpenAPI à /docs est générée à partir du modèle lui-même. Le code et la documentation ne se désynchronisent donc jamais.

4. Contraintes des données avec Field

Vérifier le type ne suffit pas. Par exemple, quantity = -5 est toujours un int valide. Vous ajoutez des contraintes avec Field, combiné avec Annotated :

from decimal import Decimal
from typing import Annotated

from pydantic import BaseModel, EmailStr, Field

Sku = Annotated[str, Field(min_length=3, max_length=20, pattern=r"^[A-Za-z0-9-]+$")]


class OrderCreate(BaseModel):
    customer_email: EmailStr
    product_sku: Sku
    quantity: Annotated[int, Field(gt=0, le=100)]
    unit_price: Annotated[Decimal, Field(gt=0, max_digits=10, decimal_places=2)]
    discount: Annotated[Decimal, Field(ge=0, decimal_places=2)] = Decimal("0")
    note: Annotated[str | None, Field(max_length=200)] = None

Chaque ligne fait les choses suivantes :

  • EmailStr vérifie le format de l’email correctement, pas seulement s’il contient un @.
  • Sku est un type réutilisable. Il nécessite une longueur de 3 à 20 caractères et ne contient que des lettres, des chiffres et des traits d’union.
  • quantity doit être supérieur à 0 et ne pas dépasser 100.
  • unit_price utilise Decimal au lieu de float pour éviter les erreurs d’arrondi lors du calcul du montant. Valeur maximale : 10 chiffres, 2 décimales.
  • discount et note ont des valeurs par défaut, ils sont donc optionnels. Les champs sans valeur par défaut sont obligatoires.

Envoyer quantity: 0 donnera :

{"type":"greater_than","loc":["body","quantity"],"msg":"Input should be greater than 0","input":0,"ctx":{"gt":0}}

Remarque : par défaut, Pydantic s’exécute en mode lax. La chaîne "5" sera coercée en nombre 5 sans erreur. Si vous voulez refuser strictement, ajoutez model_config = ConfigDict(strict=True) au modèle, ou utilisez Field(strict=True) pour chaque champ.

5. Valider les paramètres path et query

Non seulement le body, mais le path et la query sont aussi validés selon le même mécanisme :

from fastapi import HTTPException, Path, Query

orders_db: dict[int, dict] = {}


@app.get("/orders/{order_id}")
def get_order(order_id: Annotated[int, Path(gt=0)]):
    if order_id not in orders_db:
        raise HTTPException(status_code=404, detail="Order not found")
    return orders_db[order_id]


@app.get("/orders")
def list_orders(
    page: Annotated[int, Query(ge=1)] = 1,
    size: Annotated[int, Query(ge=1, le=50)] = 10,
    sku: Annotated[str | None, Query(min_length=3)] = None,
):
    items = [o for o in orders_db.values() if sku is None or o["product_sku"] == sku]
    start = (page - 1) * size
    return items[start : start + size]

Résultat avec différentes requêtes :

  • GET /orders/0 retourne 422, car order_id doit être supérieur à 0.
  • GET /orders?size=500 retourne 422, car size est au maximum 50.
  • GET /orders?page=2&size=5 s’exécute normalement. Les deux valeurs page et size ont été converties en int avant d’entrer dans la fonction.

Faites la distinction entre 422 et 404. L’erreur 422 signifie que la requête a un format incorrect. L’erreur 404 est de la logique métier, vous devez donc raise HTTPException vous-même.

6. Validateurs personnalisés

Il y a des règles qui ne peuvent pas être exprimées avec Field. Vous utilisez alors field_validator pour un champ, ou model_validator pour une règle impliquant plusieurs champs.

from pydantic import field_validator, model_validator

FORBIDDEN_DOMAINS = {"mailinator.com", "tempmail.com"}


class OrderCreate(BaseModel):
    # ... les champs comme à la section 4 ...

    @field_validator("product_sku")
    @classmethod
    def normalize_sku(cls, value: str) -> str:
        return value.upper()

    @field_validator("customer_email")
    @classmethod
    def reject_disposable_email(cls, value: str) -> str:
        domain = value.split("@")[-1].lower()
        if domain in FORBIDDEN_DOMAINS:
            raise ValueError("Disposable email is not allowed")
        return value

    @model_validator(mode="after")
    def check_discount(self) -> "OrderCreate":
        total = self.unit_price * self.quantity
        if self.discount > total:
            raise ValueError("Discount cannot exceed order total")
        return self

Explication pour chaque validateur :

  • normalize_sku s’exécute après que Field ait vérifié le pattern. Il normalise le SKU en majuscules, de sorte que "tshirt-01" est stocké sous "TSHIRT-01". Un validateur peut transformer la valeur, du moment qu’il retourne la nouvelle valeur.
  • reject_disposable_email lève une ValueError. Pydantic convertit cette erreur en erreur de validation, et le message a le préfixe "Value error, ".
  • check_discount s’exécute avec mode="after", c’est-à-dire quand tous les champs sont valides et ont le bon type. Grâce à cela, vous pouvez multiplier Decimal en toute sécurité. N’oubliez pas de return self.

Remarque : n’appelez pas la base de données ou des API externes dans un validateur. Par exemple, vérifier si « le SKU existe en stock » est de la logique métier. Mettez-le dans une couche de service ou une dépendance. Un validateur doit être une fonction pure, rapide et facile à tester.

7. response_model : contrôler les données retournées

Vous stockez souvent plus d’informations que vous ne voulez retourner au client, par exemple des notes internes. Séparez les modèles d’entrée et de sortie :

class OrderRead(BaseModel):
    id: int
    customer_email: EmailStr
    product_sku: str
    quantity: int
    total: Decimal


@app.post("/orders", status_code=201, response_model=OrderRead)
def create_order(order: OrderCreate):
    order_id = len(orders_db) + 1
    record = {
        "id": order_id,
        **order.model_dump(),
        "total": order.unit_price * order.quantity - order.discount,
        "internal_flag": "manual-review",
    }
    orders_db[order_id] = record
    return record

La fonction retourne un dict contenant note et internal_flag. Cependant, response_model=OrderRead filtre la réponse pour ne garder que les champs déclarés :

{"id":1,"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":2,"total":"39.98"}

Il y a deux points à noter :

  • Decimal est sérialisé en chaîne "39.98" pour préserver la précision. Le client a besoin de le savoir.
  • La réponse est aussi validée. Si vous oubliez le champ total, FastAPI lève ResponseValidationError et retourne 500, pas 422. C’est une erreur du serveur, pas du client.

8. Personnaliser le format de l’erreur 422

Le format par défaut est assez détaillé. Beaucoup d’équipes veulent une structure compacte et cohérente pour le frontend. Vous remplacez le gestionnaire de RequestValidationError :

from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    errors = [
        {
            "field": ".".join(str(part) for part in err["loc"][1:]) or "body",
            "message": err["msg"],
        }
        for err in exc.errors()
    ]
    return JSONResponse(
        status_code=422,
        content={"code": "VALIDATION_FAILED", "errors": errors},
    )

Comment le gestionnaire traite chaque erreur :

  • err["loc"][1:] abandonne le premier élément (body, query, path) et joint le reste avec des points.
  • Les erreurs de model_validator ne sont attachées à aucun champ. Dans ce cas, loc ne contient que ("body",), donc le gestionnaire assigne "body".

Envoyer une commande avec quantity: 0 et un email jetable donnera :

{"code":"VALIDATION_FAILED","errors":[
  {"field":"customer_email","message":"Value error, Disposable email is not allowed"},
  {"field":"quantity","message":"Input should be greater than 0"}]}

Pydantic collecte toutes les erreurs des champs d’un coup, au lieu de s’arrêter à la première. Le client peut ainsi tout corriger en une seule fois.

9. Test avec pytest

TestClient appelle l’app directement, sans avoir besoin d’exécuter le serveur. Créez le fichier test_main.py à côté de main.py :

import pytest
from fastapi.testclient import TestClient

from main import app, orders_db

client = TestClient(app)

VALID_ORDER = {
    "customer_email": "an@example.com",
    "product_sku": "tshirt-01",
    "quantity": 2,
    "unit_price": "19.99",
}


@pytest.fixture(autouse=True)
def clear_db():
    orders_db.clear()


def test_create_order_returns_normalized_sku_and_total():
    response = client.post("/orders", json=VALID_ORDER)

    assert response.status_code == 201
    body = response.json()
    assert body["product_sku"] == "TSHIRT-01"
    assert body["total"] == "39.98"
    assert "internal_flag" not in body


def test_invalid_quantity_and_email_are_reported_together():
    payload = {**VALID_ORDER, "quantity": 0, "customer_email": "x@mailinator.com"}

    response = client.post("/orders", json=payload)

    assert response.status_code == 422
    fields = {e["field"] for e in response.json()["errors"]}
    assert fields == {"quantity", "customer_email"}


def test_discount_greater_than_total_is_rejected():
    payload = {**VALID_ORDER, "discount": "100.00"}

    response = client.post("/orders", json=payload)

    assert response.status_code == 422
    assert response.json()["errors"][0] == {
        "field": "body",
        "message": "Value error, Discount cannot exceed order total",
    }


def test_page_size_over_limit_is_rejected():
    assert client.get("/orders?size=500").status_code == 422

Exécutez les tests :

pytest -q
....                                                    [100%]
4 passed in 0.31s

La fixture clear_db réinitialise orders_db avant chaque test, aidant les tests à rester indépendants les uns des autres. De plus, vous pouvez tester un modèle isolément sans HTTP, en utilisant OrderCreate.model_validate(...) combiné avec pytest.raises(ValidationError). Cette approche convient bien quand le modèle a de nombreuses règles complexes.

10. Erreurs courantes

  • Utiliser float pour l’argent. 0.1 + 0.2 n’égale pas 0.3. Utilisez Decimal avec max_digits et decimal_places.
  • Oublier @classmethod ou l’ordre des décorateurs. @field_validator doit se trouver au-dessus de @classmethod. Sinon, Pydantic v2 signale une erreur lors de l’importation.
  • Oublier return dans un validateur. Un field_validator sans return affectera None au champ. Un model_validator(mode="after") doit retourner self.
  • Utiliser la syntaxe Pydantic v1. @validator, class Config, .dict() sont dépréciés. Utilisez @field_validator, model_config, .model_dump().
  • Confondre 422 et 500. Une entrée incorrecte donne 422. Une réponse ne correspondant pas à response_model donne 500. Si vous voyez 500, vérifiez les données que vous retournez.
  • Utiliser le même modèle pour l’entrée et la sortie. Cela risque d’exposer des champs internes et force le client à envoyer des champs comme id. Séparez OrderCreate et OrderRead.

Remarque : la validation au niveau de l’API ne remplace pas les contraintes de base de données. Les contraintes comme UNIQUE ou NOT NULL sont toujours nécessaires. Les requêtes peuvent entrer dans le système par de nombreux chemins différents, par exemple les tâches de fond ou les scripts de migration.

11. Conclusion

FastAPI combiné à Pydantic transforme les annotations de type en contrats de données, avec trois avantages principaux :

  • Les requêtes invalides sont bloquées à l’entrée.
  • Les erreurs sont retournées de manière complète et structurée.
  • La documentation OpenAPI correspond toujours au code.

Dans cet article, vous avez utilisé Field pour des contraintes simples, Path et Query pour les paramètres URL, field_validator et model_validator pour des règles personnalisées. Vous avez aussi utilisé response_model pour filtrer les données de sortie, modifier le format des erreurs 422, et tester le tout avec TestClient.

Principe à retenir : laissez les validateurs vérifier la forme des données, et placez la logique métier dans la couche de service.

pythonfastapipydanticrest-apivalidation