Remarque
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de vous connecter ou de modifier des répertoires.
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de modifier des répertoires.
FastAPI est un framework web Python moderne pour la création d’API. Combiné à mssql-python, vous pouvez construire des API REST haute performance soutenues par Microsoft SQL et Azure SQL Database.
Logiciels requis
- Python 3.10 ou version ultérieure.
- Les packages
mssql-python,fastapi,uvicorn,pydanticetPyJWT. Installez tout avecpip install fastapi uvicorn mssql-python pydantic pyjwt. - Installez les prérequis ponctuels spécifiques au système d'exploitation. Les utilisateurs Windows peuvent sauter cette étape. Pour tous les détails sur la plateforme, voir Installer mssql-python.
Créer une base de données SQL
Créer ou connecter une base de données SQL sur l’une des plateformes suivantes :
Les exemples de cet article utilisent la base de données d’exemples AdventureWorksLT , en particulier la SalesLT.Product table. Si vous n’avez pas AdventureWorksLT installé, consultez les bases de données d’exemple AdventureWorks.
Configuration du projet
Créer un environnement virtuel
Créez et activez un environnement virtuel afin que les paquets de ce projet restent isolés des autres installations Python. Cette étape évite également le problème courant d’installer des paquets dans un interpréteur lors de l’exécution de votre application ou de tests avec un autre.
py -m venv .venv
.\.venv\Scripts\Activate.ps1
Après avoir activé l’environnement, python, pip et pytest pointent tous vers le même interpréteur. Exécutez les commandes restantes de cet article depuis l’environnement activé.
Note
Sous Windows sur Arm, créez l’environnement avec un build Arm64 de Python afin que mssql-python et ses dépendances s’installent à partir de wheels précompilés. Sur une machine où plusieurs versions de Python sont installées, py -m venv peut sélectionner une version ou une architecture différente de celle attendue ; vérifiez avec python -c "import sys, sysconfig; print(sys.version, sysconfig.get_platform())" après avoir activé. Si pip tente de compiler cryptography depuis les sources (erreur liée à la chaîne d’outils Rust et OpenSSL), installez d’abord une version fournie sous forme de wheel avec pip install --only-binary=:all: cryptography, puis installez le reste.
Installer des dépendances
Installez les packages requis avec pip :
pip install fastapi uvicorn mssql-python pydantic pyjwt
Structure du projet
Organisez votre projet avec des modules distincts pour la base de données, les schémas et les opérations CRUD :
my_api/
├── main.py
├── database.py
├── models.py
├── schemas.py
├── crud.py
├── test_api.py
└── routers/
└── products.py
Gestion des connexions à la base de données
FastAPI utilise l’injection de dépendances pour fournir des ressources comme les connexions de bases de données aux gestionnaires de routes. Le modèle présenté dans cette section crée un gestionnaire de contexte qui ouvre une connexion, fournit un curseur et gère automatiquement la validation, l’annulation ou la fermeture.
Créez database.py
La get_connection_string() fonction construit la chaîne de connexion ODBC à partir de valeurs de configuration. Le get_db() gestionnaire de contexte et le get_db_dependency() générateur suivent tous deux le même schéma : ouvrir une connexion, produire un curseur, valider la transaction en cas de succès, annuler la transaction en cas d’erreur, et toujours fermer la connexion une fois l’opération terminée. FastAPI appelle Depends()get_db_dependency() une fois par requête et gère son cycle de vie.
# database.py
import mssql_python
from contextlib import contextmanager
from typing import Generator
# Configuration
DATABASE_CONFIG = {
"server": "<server>.database.windows.net",
"database": "<database>",
}
def get_connection_string() -> str:
"""Build connection string from config."""
return (
f"Server={DATABASE_CONFIG['server']};"
f"Database={DATABASE_CONFIG['database']};"
"Authentication=ActiveDirectoryDefault;"
"Encrypt=yes"
)
Note
ActiveDirectoryDefault utilise DefaultAzureCredential, qui tente successivement d’utiliser plusieurs fournisseurs d’informations d’identification. La première connexion peut être lente car le SDK parcourt la chaîne jusqu’à ce qu’il trouve un fournisseur fonctionnel. En production, si vous savez quel type d’identifiant votre environnement utilise, spécifiez-le directement (par exemple, ActiveDirectoryMSI pour l’identité gérée) afin d’éviter la marche en chaîne. Pour plus d’informations, consultez Authentification Microsoft Entra.
@contextmanager
def get_db() -> Generator:
"""Database connection context manager for FastAPI dependency injection."""
conn = mssql_python.connect(get_connection_string())
cursor = conn.cursor()
try:
yield cursor
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cursor.close()
conn.close()
def get_db_dependency():
"""FastAPI dependency for database cursor."""
conn = mssql_python.connect(get_connection_string())
cursor = conn.cursor()
try:
yield cursor
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cursor.close()
conn.close()
Modèles pydantiques
Les modèles pydantiques définissent la forme et les règles de validation pour les données de requête et de réponse. FastAPI utilise ces modèles pour analyser le JSON entrant, valider les contraintes de champ et générer automatiquement la documentation OpenAPI.
Créez schemas.py
Séparer les schémas en Base, Create, Update, et répondre aux variantes. Le Base schéma contient des champs partagés, Create en hérite pour les opérations d’insertion, et Update rend tous les champs optionnels pour les mises à jour partielles.
# schemas.py
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from typing import Optional
from datetime import datetime
# Product schemas
class ProductBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
product_number: str = Field(..., min_length=1, max_length=25)
price: float = Field(..., gt=0)
color: Optional[str] = Field(None, max_length=50)
size: Optional[str] = Field(None, max_length=50)
category_id: Optional[int] = None
class ProductCreate(ProductBase):
pass
class ProductUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
product_number: Optional[str] = Field(None, min_length=1, max_length=25)
price: Optional[float] = Field(None, gt=0)
color: Optional[str] = Field(None, max_length=50)
size: Optional[str] = Field(None, max_length=50)
category_id: Optional[int] = None
class Product(ProductBase):
id: int
model_config = ConfigDict(from_attributes=True)
# Pagination
class PaginatedResponse(BaseModel):
items: list
total: int
page: int
page_size: int
pages: int
Opérations CRUD
Encapsulez les requêtes de base de données dans une classe dédiée afin d’alléger les gestionnaires de routes. Chaque méthode statique prend un curseur (injecté par FastAPI) et gère une opération à l’aide de requêtes paramétrées (%(name)s placeholders avec un dictionnaire de valeurs) afin d’empêcher l’injection SQL. Cette séparation facilite les tests et la réutilisation de la logique métier.
Créez crud.py
# crud.py
from typing import Optional, List
from schemas import ProductCreate, ProductUpdate, Product
class ProductCRUD:
"""CRUD operations for products."""
@staticmethod
def get(cursor, product_id: int) -> Optional[dict]:
cursor.execute("""
SELECT ProductID, Name, ProductNumber, ListPrice, Color, Size
FROM SalesLT.Product
WHERE ProductID = %(id)s
""", {"id": product_id})
row = cursor.fetchone()
if row:
return {
"id": row.ProductID,
"name": row.Name,
"product_number": row.ProductNumber,
"price": float(row.ListPrice),
"color": row.Color,
"size": row.Size
}
return None
@staticmethod
def get_all(cursor, skip: int = 0, limit: int = 100) -> List[dict]:
cursor.execute("""
SELECT ProductID, Name, ProductNumber, ListPrice, Color, Size
FROM SalesLT.Product
ORDER BY ProductID
OFFSET %(skip)s ROWS
FETCH NEXT %(limit)s ROWS ONLY
""", {"skip": skip, "limit": limit})
return [{
"id": row.ProductID,
"name": row.Name,
"product_number": row.ProductNumber,
"price": float(row.ListPrice),
"color": row.Color,
"size": row.Size
} for row in cursor.fetchall()]
@staticmethod
def count(cursor) -> int:
cursor.execute("SELECT COUNT(*) FROM SalesLT.Product")
return cursor.fetchval()
@staticmethod
def create(cursor, product: ProductCreate) -> dict:
cursor.execute("""
INSERT INTO SalesLT.Product (Name, ProductNumber, ListPrice, Color, Size, ProductCategoryID, StandardCost, SellStartDate)
OUTPUT INSERTED.ProductID, INSERTED.Name, INSERTED.ProductNumber,
INSERTED.ListPrice, INSERTED.Color, INSERTED.Size
VALUES (%(name)s, %(product_number)s, %(price)s, %(color)s, %(size)s, %(category_id)s, 0, GETDATE())
""", {
"name": product.name,
"product_number": product.product_number,
"price": product.price,
"color": product.color,
"size": product.size,
"category_id": product.category_id
})
row = cursor.fetchone()
return {
"id": row.ProductID,
"name": row.Name,
"product_number": row.ProductNumber,
"price": float(row.ListPrice),
"color": row.Color,
"size": row.Size
}
@staticmethod
def update(cursor, product_id: int, product: ProductUpdate) -> Optional[dict]:
# Build dynamic update
updates = []
params = {"id": product_id}
if product.name is not None:
updates.append("Name = %(name)s")
params["name"] = product.name
if product.product_number is not None:
updates.append("ProductNumber = %(product_number)s")
params["product_number"] = product.product_number
if product.price is not None:
updates.append("ListPrice = %(price)s")
params["price"] = product.price
if product.category_id is not None:
updates.append("ProductCategoryID = %(category_id)s")
params["category_id"] = product.category_id
if not updates:
return ProductCRUD.get(cursor, product_id)
cursor.execute(f"""
UPDATE SalesLT.Product SET {', '.join(updates)}
OUTPUT INSERTED.ProductID, INSERTED.Name, INSERTED.ProductNumber,
INSERTED.ListPrice, INSERTED.Color, INSERTED.Size
WHERE ProductID = %(id)s
""", params)
row = cursor.fetchone()
if row:
return {
"id": row.ProductID,
"name": row.Name,
"product_number": row.ProductNumber,
"price": float(row.ListPrice),
"color": row.Color,
"size": row.Size
}
return None
@staticmethod
def delete(cursor, product_id: int) -> bool:
cursor.execute("""
DELETE FROM SalesLT.Product WHERE ProductID = %(id)s
""", {"id": product_id})
return cursor.rowcount > 0
@staticmethod
def search(cursor, query: str, skip: int = 0, limit: int = 100) -> List[dict]:
cursor.execute("""
SELECT ProductID, Name, ProductNumber, ListPrice, Color, Size
FROM SalesLT.Product
WHERE Name LIKE %(query)s OR ProductNumber LIKE %(query)s
ORDER BY ProductID
OFFSET %(skip)s ROWS
FETCH NEXT %(limit)s ROWS ONLY
""", {"query": f"%{query}%", "skip": skip, "limit": limit})
return [{
"id": row.ProductID,
"name": row.Name,
"product_number": row.ProductNumber,
"price": float(row.ListPrice),
"color": row.Color,
"size": row.Size
} for row in cursor.fetchall()]
Application FastAPI
Créez main.py
Le module principal relie tout ensemble. Chaque route déclare cursor = Depends(get_db_dependency), ce qui indique à FastAPI d’appeler le générateur, de transmettre au gestionnaire le curseur produit, puis d’effectuer le nettoyage. FastAPI valide également les corps de requêtes par rapport à vos schémas Pydantic avant que le gestionnaire ne s’exécute.
# main.py
from fastapi import FastAPI, HTTPException, Depends, Query
from typing import List
from database import get_db_dependency
from schemas import Product, ProductCreate, ProductUpdate, PaginatedResponse
from crud import ProductCRUD
app = FastAPI(
title="Product API",
description="REST API for products using mssql-python",
version="1.0.0"
)
@app.get("/")
def root():
return {"message": "Product API", "docs": "/docs"}
@app.get("/products", response_model=PaginatedResponse)
def list_products(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
cursor = Depends(get_db_dependency)
):
"""List all products with pagination."""
skip = (page - 1) * page_size
items = ProductCRUD.get_all(cursor, skip=skip, limit=page_size)
total = ProductCRUD.count(cursor)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size
}
@app.get("/products/{product_id}", response_model=Product)
def get_product(product_id: int, cursor = Depends(get_db_dependency)):
"""Get a specific product by ID."""
product = ProductCRUD.get(cursor, product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product
@app.post("/products", response_model=Product, status_code=201)
def create_product(product: ProductCreate, cursor = Depends(get_db_dependency)):
"""Create a new product."""
return ProductCRUD.create(cursor, product)
@app.put("/products/{product_id}", response_model=Product)
def update_product(
product_id: int,
product: ProductUpdate,
cursor = Depends(get_db_dependency)
):
"""Update an existing product."""
updated = ProductCRUD.update(cursor, product_id, product)
if not updated:
raise HTTPException(status_code=404, detail="Product not found")
return updated
@app.delete("/products/{product_id}", status_code=204)
def delete_product(product_id: int, cursor = Depends(get_db_dependency)):
"""Delete a product."""
if not ProductCRUD.delete(cursor, product_id):
raise HTTPException(status_code=404, detail="Product not found")
@app.get("/products/search/", response_model=List[Product])
def search_products(
q: str = Query(..., min_length=1),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
cursor = Depends(get_db_dependency)
):
"""Search products by name or product number."""
skip = (page - 1) * page_size
return ProductCRUD.search(cursor, q, skip=skip, limit=page_size)
# Health check endpoint
@app.get("/health")
def health_check(cursor = Depends(get_db_dependency)):
"""Check database connectivity."""
try:
cursor.execute("SELECT 1")
return {"status": "healthy", "database": "connected"}
except Exception as e:
raise HTTPException(status_code=503, detail=f"Database unhealthy: {str(e)}")
Exécuter l’application
uvicorn main:app --reload --host 0.0.0.0 --port 8000
Gestion des erreurs
FastAPI permet d’enregistrer des gestionnaires d’exceptions globaux pour des types d’exceptions spécifiques. Lorsque vous détectez mssql_python.DatabaseError et mssql_python.IntegrityError, FastAPI renvoie des erreurs JSON structurées avec des codes d’état HTTP appropriés au lieu de réponses génériques 500.
Gestionnaire d’exception global
Ajoutez ces gestionnaires à main.py, juste après la ligne app = FastAPI(...). FastAPI exécute le gestionnaire correspondant chaque fois qu’une route lève une exception de ce type, vous n’avez donc pas besoin d’un bloc try/except dans chaque route.
# main.py
from fastapi import Request
from fastapi.responses import JSONResponse
import mssql_python
@app.exception_handler(mssql_python.DatabaseError)
async def database_exception_handler(request: Request, exc: mssql_python.DatabaseError):
"""Handle database errors globally."""
return JSONResponse(
status_code=500,
content={"detail": "Database error occurred", "type": "database_error"}
)
@app.exception_handler(mssql_python.IntegrityError)
async def integrity_exception_handler(request: Request, exc: mssql_python.IntegrityError):
"""Handle integrity constraint violations."""
error_msg = str(exc)
if "UNIQUE" in error_msg:
return JSONResponse(
status_code=409,
content={"detail": "Resource already exists", "type": "duplicate_error"}
)
elif "FOREIGN KEY" in error_msg:
return JSONResponse(
status_code=400,
content={"detail": "Referenced resource not found", "type": "reference_error"}
)
return JSONResponse(
status_code=400,
content={"detail": "Data integrity error", "type": "integrity_error"}
)
Note
La suppression d’un produit auquel d’autres lignes font encore référence déclenche mssql_python.IntegrityError en raison de la contrainte de clé étrangère, et le gestionnaire renvoie un code 400 au lieu de supprimer la ligne. Dans l’exemple AdventureWorksLT, la plupart des produits de SalesLT.Product sont référencés par SalesLT.SalesOrderDetail, donc DELETE échoue dans leur cas par conception. Pour tester une suppression réussie, créez un produit avec POST /products et supprimez celui-ci, ou supprimez d’abord les lignes de référencement.
Regroupement de connexions
Sans pooling de connexions, chaque requête ouvre et ferme une connexion TCP vers Microsoft SQL, ce qui ajoute de la latence. Le pooling de connexions maintient un ensemble de connexions inactives prêtes à être réutilisées. Appelez mssql_python.pooling() une fois au démarrage. Lorsque la mise en pool est activée, conn.close() dans get_db_dependency() renvoie la connexion au pool de connexions au lieu de fermer réellement la connexion.
Module de base de données amélioré
Activez le pooling en appelant mssql_python.pooling() au démarrage et configurez-le avec des réglages de taille maximale et de délai appropriés :
# database.py with connection pooling
import mssql_python
from contextlib import contextmanager
import os
# Configure pool
mssql_python.pooling(max_size=20, idle_timeout=300)
DATABASE_URL = os.getenv(
"DATABASE_URL",
"Server=<server>.database.windows.net;Database=<database>;"
"Authentication=ActiveDirectoryDefault;Encrypt=yes"
)
def get_db_dependency():
"""FastAPI dependency with connection pooling."""
conn = mssql_python.connect(DATABASE_URL)
cursor = conn.cursor()
try:
yield cursor
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cursor.close()
conn.close() # Returns to pool
Middleware d’authentification
Vous pouvez combiner l’accès à la base de données avec l’authentification en chaînant les dépendances FastAPI. L’exemple suivant valide un jeton JWT Bearer, recherche la fiche de la personne correspondante dans la base de données d’exemple AdventureWorksLT, puis met le résultat à la disposition des routes protégées.
# auth.py
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
cursor = Depends(get_db_dependency)
):
"""Validate JWT and return the matching AdventureWorksLT person."""
try:
token = credentials.credentials
# Replace with a strong secret loaded from environment variables
payload = jwt.decode(token, "your-secret-key", algorithms=["HS256"])
person_id = int(payload.get("sub"))
if not person_id:
raise HTTPException(status_code=401, detail="Invalid token")
cursor.execute("""
SELECT BusinessEntityID, FirstName, LastName
FROM Person.Person
WHERE BusinessEntityID = %(id)s
""", {"id": person_id})
person = cursor.fetchone()
if not person:
raise HTTPException(status_code=401, detail="User not found")
return {
"id": person.BusinessEntityID,
"first_name": person.FirstName,
"last_name": person.LastName
}
except (TypeError, ValueError):
raise HTTPException(status_code=401, detail="Invalid token subject")
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
# Protected endpoint
@app.get("/me")
def get_me(current_user: dict = Depends(get_current_user)):
return current_user
Testing
FastAPI propose un TestClient module intégré httpx qui envoie des requêtes à votre application sans démarrer un vrai serveur HTTP. Écrivez des tests avec pytest pour vérifier les routes, les codes d’état et la structure des réponses.
Avant d’exécuter les tests de cette section, installez les dépendances de test :
pip install pytest httpx
Note
Si vous utilisez la dernière version de Starlette ou si vous mettez en place un nouvel environnement, préférez httpx2 à httpx. Les versions récentes de Starlette utilisent httpx2 pour TestClient et émettent un avertissement de dépréciation lorsque seule httpx est installée. Installez-le avec pip install pytest httpx2.
Configuration de test
Créez un fichier de test qui sert TestClient à vérifier le comportement des routes et les schémas de réponse :
# test_api.py
from fastapi.testclient import TestClient
from main import app
import uuid
import pytest
client = TestClient(app)
def test_list_products():
response = client.get("/products")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
def test_create_product():
suffix = uuid.uuid4().hex[:8]
name = f"Test Product {suffix}"
product_data = {
"name": name,
"product_number": f"TEST-{suffix}",
"price": 19.99,
"color": "Red",
"size": "M",
"category_id": 1
}
response = client.post("/products", json=product_data)
assert response.status_code == 201
data = response.json()
assert data["name"] == name
assert data["price"] == 19.99
def test_get_product_not_found():
response = client.get("/products/99999")
assert response.status_code == 404
def test_health_check():
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
Exécutez les tests avec pytest depuis la racine du projet, le même répertoire que main.py:
pytest
Ces tests s’exécutent sur votre base de données en direct plutôt que sur des mocks, donc test_create_product insère une vraie ligne dans SalesLT.Product. Dans AdventureWorksLT, Name et ProductNumber ont des contraintes uniques, donc le test génère une valeur unique pour chacun à chaque exécution. Si vous codez ces valeurs en dur à la place, le test échoue avec un conflit lors de la deuxième exécution, sauf si vous supprimez d’abord la ligne.
Configuration du déploiement
Utilisez Pydantic BaseSettings pour charger la configuration à partir des variables et .env fichiers d’environnement. Cette approche empêche les secrets d’entrer dans le code source et facilite le passage d’un environnement à l’autre. Installez le package de paramètres avec pip install pydantic-settings.
Variables d’environnement
Créez un module de paramètres qui charge la configuration à partir des variables d’environnement, vous permettant de gérer des secrets et des valeurs spécifiques au déploiement en dehors de votre code :
# config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_server: str = "<server>.database.windows.net"
database_name: str = "<database>"
pool_size: int = 10
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()
def get_connection_string() -> str:
return (
f"Server={settings.database_server};"
f"Database={settings.database_name};"
"Authentication=ActiveDirectoryDefault;"
"Encrypt=yes"
)
Puis mettre à jour database.py pour importer get_connection_string depuis config au lieu de définir sa propre copie. En supprimant la fonction dupliquée, vous vous assurez que l’application lit les paramètres de connexion d’une seule source.
# database.py
from config import get_connection_string