Ескертпе
Бұл бетке кіру үшін қатынас шегін айқындау қажет. Жүйеге кіруді немесе каталогтарды өзгертуді байқап көруге болады.
Бұл бетке кіру үшін қатынас шегін айқындау қажет. Каталогтарды өзгертуді байқап көруге болады.
FastAPI — это современный веб-фреймворк на Python для создания API. В сочетании с mssql-python вы можете создавать высокопроизводительные REST API, поддерживаемые Microsoft SQL и База данных SQL Azure.
Необходимые условия
- Python 3.10 или более поздней версии.
-
mssql-python,fastapi,uvicorn,pydanticиPyJWTпакеты. Установите всё с помощьюpip install fastapi uvicorn mssql-python pydantic pyjwt. - Установите единовременные предварительные условия для операционной системы. Пользователи Windows могут пропустить этот шаг. Для полной информации о платформе см. Установить mssql-python.
apk add libtool krb5-libs krb5-dev
Создание базы данных SQL
Создайте или подключитесь к SQL-базе данных на одной из следующих платформ:
В примерах этой статьи используется пример базы данных AdventureWorksLT, а именно таблица SalesLT.Product. Если у вас не установлен AdventureWorksLT, посмотрите примеры баз данных AdventureWorks.
Настройка проекта
Создание виртуальной среды
Создайте и активируйте виртуальную среду, чтобы пакеты проекта оставались изолированными от других установок на Python. Этот шаг также предотвращает распространённую проблему установки пакетов в один интерпретатор во время запуска приложения или тестов с другим.
py -m venv .venv
.\.venv\Scripts\Activate.ps1
После активации окружения python, pip и pytest все указывают на один и тот же интерпретатор. Выполните оставшиеся команды в этой статье из активированной среды.
Замечание
В Windows on Arm создайте среду, используя сборку Python для Arm64, чтобы mssql-python и его зависимости устанавливались из предварительно собранных wheel-пакетов. На компьютере, где установлено несколько версий Python, py -m venv может выбрать не ту версию или архитектуру, которую вы ожидаете, поэтому после активации проверьте это с помощью python -c "import sys, sysconfig; print(sys.version, sysconfig.get_platform())". Если pip пытается собрать cryptography из исходников (ошибка в цепочке инструментов Rust и OpenSSL), сначала установите версию из wheel-пакета с помощью pip install --only-binary=:all: cryptography, а затем установите всё остальное.
Установка зависимостей
Установите необходимые пакеты с помощью pip:
pip install fastapi uvicorn mssql-python pydantic pyjwt
структура проекта
Организуйте свой проект с помощью отдельных модулей для баз данных, схем и операций CRUD:
my_api/
├── main.py
├── database.py
├── models.py
├── schemas.py
├── crud.py
├── test_api.py
└── routers/
└── products.py
Управление подключением к базе данных
FastAPI использует инъекцию зависимостей для предоставления ресурсов, таких как соединения с базой данных, обработчикам маршрутов. Шаблон, приведённый в этом разделе, создаёт менеджер контекста, который открывает соединение, возвращает курсор и автоматически обрабатывает подтверждение транзакции, откат и закрытие.
Создайте database.py
get_connection_string() Функция строит ODBC строка подключения на основе значений конфигурации. Контекстный менеджер get_db() и генератор get_db_dependency() следуют одному и тому же шаблону: открывают соединение, возвращают курсор, подтверждают транзакцию в случае успеха, откатывают её при ошибке и в любом случае закрывают соединение по завершении. FastAPI вызывает Depends()get_db_dependency() один раз на запрос и управляет его жизненным циклом.
# 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"
)
Замечание
ActiveDirectoryDefault использует DefaultAzureCredential, который последовательно пробует несколько поставщиков учетных данных. Первое соединение может быть медленным, потому что SDK идёт по цепочке, пока не найдёт работающего провайдера. В продакшене, если вы знаете, какой тип учетных данных использует ваша среда, укажите его напрямую (например, ActiveDirectoryMSI для управляемой идентичности), чтобы избежать цепной ходьбы. Дополнительные сведения см. в разделе проверки подлинности 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()
Пидантические модели
Пидантические модели определяют правила формы и валидации данных запроса и ответа. FastAPI использует эти модели для разбора входящих JSON, проверки ограничений на поля и автоматической генерации документации OpenAPI.
Создайте schemas.py
Разделите схемы на Base, Create, Update и варианты ответа. Схема Base содержит общие поля, Create наследует их для операций вставки и Update делает все поля необязательными для частичных обновлений.
# 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-операции
Инкапсулируйте запросы к базе данных в выделенный класс, чтобы сделать обработчики маршрутов тонкими. Каждый статический метод принимает курсор (передаваемый FastAPI) и выполняет одну операцию с использованием параметризованных запросов (%(name)s плейсхолдеров со словарём значений), чтобы предотвратить SQL-инъекции. Такое разделение облегчает тестирование и повторное использование бизнес-логики.
Создайте 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
Создайте main.py
Основной модуль соединяет всё вместе. Каждый маршрут объявляет cursor = Depends(get_db_dependency), что говорит FastAPI вызвать генератор, передать полученный курсор обработчику и затем очистить. FastAPI также проверяет тела запросов по вашим схемам Pydantic до запуска обработчика.
# 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)}")
Запуск приложения
uvicorn main:app --reload --host 0.0.0.0 --port 8000
Обработка ошибок
FastAPI позволяет регистрировать глобальные обработчики исключений для определённых типов исключений. Когда вы перехватываете mssql_python.DatabaseError и mssql_python.IntegrityError, FastAPI возвращает структурированные ошибки в формате JSON с соответствующими кодами состояния HTTP вместо универсальных ответов с кодом 500.
Глобальный обработчик исключений
Добавьте эти обработчики в main.py, сразу после строки app = FastAPI(...). FastAPI запускает соответствующий обработчик всякий раз, когда в маршруте возникает исключение этого типа, поэтому вам не нужен блок try/except в каждом маршруте.
# 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"}
)
Замечание
Удаление продукта, на который по-прежнему ссылаются другие строки, вызывает ошибку mssql_python.IntegrityError из-за ограничения внешнего ключа, и обработчик возвращает 400 вместо удаления строки. В образце AdventureWorksLT на большинство продуктов в SalesLT.Product имеются ссылки из SalesLT.SalesOrderDetail, поэтому DELETE по замыслу не работает для них. Чтобы проверить успешное удаление, создайте продукт, на который ссылается POST /products, и удалите его, либо сначала удалите ссылающиеся строки.
Пулинг соединений
Без пула соединений каждый запрос открывает и закрывает TCP-соединение с Microsoft SQL, что добавляет задержку. Механизм пула соединений поддерживает набор незанятых соединений, готовых к повторному использованию. Вызовите mssql_python.pooling() один раз при запуске. Если включён пул соединений, conn.close() в get_db_dependency() возвращает соединение в пул вместо его фактического закрытия.
Расширенный модуль базы данных
Включите пулинг, позвонив mssql_python.pooling() при запуске, и настройте его с соответствующими настройками максимального размера и тайм-аута:
# 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
Промежуточное программное обеспечение аутентификации
Вы можете объединить доступ к базе данных с аутентификацией, связывая зависимости FastAPI. Следующий пример проверяет токен носителя JWT, ищет соответствующую запись пользователя в базе примеров AdventureWorksLT и делает результат доступным для защищённых маршрутов.
# 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 предоставляет клиент TestClient, построенный на httpx, который отправляет запросы в ваше приложение, не запуская реальный HTTP-сервер. Пишите тесты с помощью pytest, чтобы проверять маршруты, коды состояния и структуры ответов.
Перед запуском тестов в этом разделе установите тестовые зависимости:
pip install pytest httpx
Замечание
Если вы используете последнюю версию Starlette или настраиваете новое окружение, используйте httpx2 вместо httpx. Последние версии Starlette используют httpx2 для TestClient и выдают предупреждение об устаревании, если установлен только httpx. Установите это с pip install pytest httpx2.
Тестовая настройка
Создайте тестовый файл, который использует 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"
Запускайте тесты с помощью pytest из корня проекта, из того же каталога, где находится main.py:
pytest
Эти тесты выполняются непосредственно в вашей рабочей базе данных, а не с моками, поэтому test_create_product вставляет реальную строку в SalesLT.Product. В AdventureWorksLT оба NameProductNumber имеют уникальные ограничения, поэтому тест генерирует уникальное значение для каждого при каждом запуске. Если вместо этого жёстко прописать эти значения, тест завершится с конфликтом при повторном запуске, если сначала не удалить эту строку.
Конфигурация развертывания
Используйте Pydantic BaseSettings для загрузки конфигурации из переменных окружения и .env файлов. Такой подход скрывает секреты от исходного кода и облегчает переключение между окружениями. Установите пакет настроек с помощью pip install pydantic-settings.
Переменные среды
Создайте модуль настроек, который загружает конфигурацию из переменных среды, позволяя управлять секретами и значениями, специфичными для развертывания, вне вашего кода:
# 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"
)
Затем обновите database.py, чтобы импортировать get_connection_string из config вместо определения собственной копии. Удаляя дублированную функцию, вы гарантируете, что приложение читает настройки соединения с одного источника.
# database.py
from config import get_connection_string