Hinweis
Für den Zugriff auf diese Seite ist eine Autorisierung erforderlich. Sie können versuchen, sich anzumelden oder das Verzeichnis zu wechseln.
Für den Zugriff auf diese Seite ist eine Autorisierung erforderlich. Sie können versuchen, das Verzeichnis zu wechseln.
FastAPI ist ein modernes Python-Webframework zum Erstellen von APIs. In Kombination mit mssql-python können Sie leistungsstarke REST-APIs bauen, die von Microsoft SQL und Azure SQL-Datenbank unterstützt werden.
Voraussetzungen
- Python 3.10 oder höher.
- Die
mssql-python,fastapi,uvicorn, ,pydantic, undPyJWTPakete. Installieren Sie alle mitpip install fastapi uvicorn mssql-python pydantic pyjwt. - Installieren Sie die einmaligen betriebsystem-spezifischen Voraussetzungen. Windows-Nutzer können diesen Schritt überspringen. Für vollständige Plattformdetails siehe Install mssql-python.
Erstellen einer SQL-Datenbank
Erstellen oder verbinden Sie sich mit einer SQL-Datenbank auf einer der folgenden Plattformen:
Die Beispiele in diesem Artikel verwenden die AdventureWorksLT-Beispieldatenbank , speziell die Tabelle SalesLT.Product . Wenn Sie AdventureWorksLT nicht installiert haben, sehen Sie sich die AdventureWorks-Beispieldatenbanken an.
Projektkonfiguration
Erstellen einer virtuellen Umgebung
Erstellen und aktivieren Sie eine virtuelle Umgebung, damit die Pakete dieses Projekts von anderen Python-Installationen isoliert bleiben. Dieser Schritt verhindert auch das häufige Problem, Pakete in einen Interpreter zu installieren, während die eigene App läuft oder mit einem anderen getestet wird.
py -m venv .venv
.\.venv\Scripts\Activate.ps1
Nachdem du die Umgebung aktiviert hast, verweisen python, pip und pytest alle auf denselben Interpreter. Führe die übrigen Befehle in diesem Artikel aus der aktivierten Umgebung aus.
Note
Unter Windows auf Arm erstellen Sie die Umgebung mit einem Arm64-Build von Python, damit mssql-python und seine Abhängigkeiten aus vorgefertigten Wheels installiert werden. Auf einem Rechner mit mehr als einer Python-Version könnte py -m venv eine andere Version oder Architektur auswählen als erwartet. Überprüfen Sie dies daher mit python -c "import sys, sysconfig; print(sys.version, sysconfig.get_platform())", nachdem Sie es aktiviert haben. Wenn pip versucht, cryptography aus dem Quellcode zu bauen (ein Fehler in der Rust- und OpenSSL-Toolchain), installiere zuerst mit pip install --only-binary=:all: cryptography eine auf einem Wheel basierende Version und dann den Rest.
Abhängigkeiten installieren
Installiere die erforderlichen Pakete mit pip:
pip install fastapi uvicorn mssql-python pydantic pyjwt
Projektstruktur
Organisieren Sie Ihr Projekt mit separaten Modulen für Datenbank, Schemata und CRUD-Operationen:
my_api/
├── main.py
├── database.py
├── models.py
├── schemas.py
├── crud.py
├── test_api.py
└── routers/
└── products.py
Datenbank-Verbindungsverwaltung
FastAPI verwendet Abhängigkeitsinjektion, um Ressourcen wie Datenbankverbindungen an Routenhandler bereitzustellen. Das Muster in diesem Abschnitt erstellt einen Kontextmanager, der eine Verbindung öffnet, einen Cursor liefert und automatisch Commit/Rollback/Closing verwaltet.
Erstellen Sie database.py
Die get_connection_string() Funktion erstellt den ODBC-Verbindungszeichenfolge aus Konfigurationswerten. Der get_db() Kontextmanager und der get_db_dependency() Generator folgen beide demselben Muster: eine Verbindung öffnen, einen Cursor zurückgeben, bei Erfolg committen, bei Fehlern ein Rollback durchführen und am Ende immer schließen. FastAPIs Depends() ruft get_db_dependency() einmal pro Anfrage auf und verwaltet seinen Lebenszyklus.
# 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 verwendet DefaultAzureCredential, das mehrere Anmeldeinformationsanbieter nacheinander ausprobiert. Die erste Verbindung kann langsam sein, weil das SDK die Kette durchläuft, bis es einen funktionierenden Anbieter findet. In der Produktion gilt: Wenn du weißt, welchen Zugangsdatentyp deine Umgebung verwendet, gib ihn direkt an (zum Beispiel ActiveDirectoryMSI für eine verwaltete Identität), um den Chain Walk zu vermeiden. Weitere Informationen finden Sie unter Microsoft Entra-Authentifizierung.
@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()
Pydantische Modelle
Pydantische Modelle definieren die Form- und Validierungsregeln für Anfrage- und Antwortdaten. FastAPI verwendet diese Modelle, um eingehendes JSON zu analysieren, Feldbeschränkungen zu validieren und OpenAPI-Dokumentation automatisch zu generieren.
Erstellen Sie schemas.py
Trenne Schemata in Base, Create, Update, und Antwortvarianten. Das Schema Base enthält gemeinsame Felder, Create erbt davon für Einfügeoperationen, und Update macht alle Felder für partielle Aktualisierungen optional.
# 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
CRUD-Vorgänge
Lagern Sie Datenbankabfragen in eine eigene Klasse aus, um Route-Handler schlank zu halten. Jede statische Methode nimmt einen Cursor (von FastAPI eingeschleust) und verarbeitet eine Operation mittels parametrisierter Abfragen (%(name)s Platzhalter mit einem Wörterbuch von Werten), um SQL-Injektion zu verhindern. Diese Trennung macht die Geschäftslogik leichter zu testen und wiederzuverwenden.
Erstellen Sie 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()]
FastAPI-Anwendung
Erstellen Sie main.py
Das Hauptmodul verbindet alles miteinander. Jede Route deklariert cursor = Depends(get_db_dependency), was FastAPI anweist, den Generator aufzurufen, den ergebenen Cursor an den Handler weiterzugeben und anschließend aufzuräumen. FastAPI validiert außerdem Anfrageinhalte anhand deiner Pydantic-Schemata, bevor der Handler ausgeführt wird.
# 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)}")
Ausführen der Anwendung
uvicorn main:app --reload --host 0.0.0.0 --port 8000
Fehlerbehandlung
FastAPI ermöglicht es, globale Ausnahmehandler für bestimmte Ausnahmetypen zu registrieren. Wenn Sie mssql_python.DatabaseError und mssql_python.IntegrityError abfangen, gibt FastAPI strukturierte JSON-Fehler mit entsprechenden HTTP-Statuscodes statt generischer 500-Antworten zurück.
Globaler Ausnahmehandler
Füge diese Handler zu main.py hinzu, direkt nach der Zeile app = FastAPI(...). FastAPI führt den Matching-Handler aus, wann immer eine Route diesen Ausnahmetyp aufruft, sodass du nicht in jeder Route einen Block try/except brauchst.
# 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
Das Löschen eines Produkts, auf das noch andere Zeilen verweisen, löst mssql_python.IntegrityError aufgrund der Fremdschlüsselbeschränkung aus, und der Handler gibt einen 400-Statuscode zurück, anstatt die Zeile zu entfernen. Im AdventureWorksLT-Beispiel werden die meisten Produkte in SalesLT.Product von SalesLT.SalesOrderDetail referenziert, daher schlägt DELETE bei ihnen absichtlich fehl. Um eine erfolgreiche Löschung zu testen, erstelle ein Produkt mit POST /products und lösche dieses Produkt oder entferne zuerst die referenzierenden Zeilen.
Verbindungspooling
Ohne Connection Pooling öffnet und schließt jede Anfrage eine TCP-Verbindung zu Microsoft SQL, was die Latenz erhöht. Connection Pooling hält eine Reihe von Leerlaufverbindungen bereit zur Wiederverwendung. Rufen Sie mssql_python.pooling() beim Start einmal auf. Bei aktiviertem Pooling gibt conn.close() in get_db_dependency() die Verbindung an den Pool zurück, anstatt sie tatsächlich zu schließen.
Erweitertes Datenbankmodul
Aktivieren Sie das Pooling, indem Sie beim Start aufrufen mssql_python.pooling() und konfigurieren Sie es mit den entsprechenden maximalen Größen- und Timeout-Einstellungen:
# 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
Authentifizierungsmiddleware
Man kann Datenbankzugriff mit Authentifizierung kombinieren, indem man FastAPI-Abhängigkeiten aneinanderreiht. Das folgende Beispiel validiert ein JWT-Trägertoken, sucht den passenden Personeneintrag in der AdventureWorksLT-Beispieldatenbank nach und stellt das Ergebnis geschützten Routen zur Verfügung.
# 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
Testen
FastAPI bietet ein auf httpx basierendes TestClient, das Anfragen an Ihre Anwendung sendet, ohne einen echten HTTP-Server zu starten. Schreibe Tests mit pytest, um Routen, Statuscodes und Antwortstrukturen zu überprüfen.
Bevor Sie die Tests in diesem Abschnitt ausführen, installieren Sie die Testabhängigkeiten:
pip install pytest httpx
Note
Wenn du auf dem neuesten Starlette bist oder eine neue Umgebung einrichten möchtest, bevorzuge httpx2 es über httpx. Neuere Starlette-Versionen verwenden httpx2 für TestClient und geben eine Deprecation-Warnung aus, wenn nur httpx installiert ist. Installieren Sie es mit pip install pytest httpx2.
Testaufbau
Erstellen Sie eine Testdatei, die zur Überprüfung von Routenverhalten und Antwortschemata verwendet wird TestClient :
# 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"
Führen Sie die Tests im Stammverzeichnis des Projekts mit pytest aus, also in demselben Verzeichnis wie main.py:
pytest
Diese Tests werden gegen deine Live-Datenbank und nicht gegen Mocks ausgeführt, daher fügt test_create_product eine echte Zeile in SalesLT.Product ein. In AdventureWorksLT haben beide Name und ProductNumber einzigartige Einschränkungen, sodass der Test für jeden bei jedem Durchlauf einen einzigartigen Wert erzeugt. Wenn du diese Werte stattdessen fest kodierst, scheitert der Test mit einem Konflikt beim zweiten Durchlauf, es sei denn, du löschst zuerst die Zeile.
Bereitstellungskonfiguration
Verwenden Sie Pydantics BaseSettings, um Konfiguration aus Umgebungsvariablen und .env-Dateien zu laden. Dieser Ansatz hält Geheimnisse aus dem Quellcode heraus und erleichtert den Wechsel zwischen den Umgebungen. Installiere das Einstellungspaket mit pip install pydantic-settings.
Umgebungsvariablen
Erstellen Sie ein Einstellungsmodul, das Konfigurationen aus Umgebungsvariablen lädt und es Ihnen ermöglicht, Geheimnisse und deployment-spezifische Werte außerhalb Ihres Codes zu verwalten:
# 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"
)
Aktualisieren Sie dann database.py, sodass get_connection_string aus config importiert wird, anstatt eine eigene Kopie davon zu definieren. Indem Sie die duplizierte Funktion entfernen, stellen Sie sicher, dass die App die Verbindungseinstellungen von einer einzigen Quelle liest.
# database.py
from config import get_connection_string