FastAPI est un framework de développement web en Python. La documentation officielle contient notamment un tutoriel complet. Le code source du projet décrit dans cet article est disponible sur sourcehut : fastapi-sandbox. Pour démarrer un nouveau projet voir aussi le modèle cookiecutter.
Création d'un projet
Le framework se base principalement sur les dépendances suivantes :
- starlette : framework web ASGI
- pydantic : validation des données
- sqlalchemy : mapping objet-relationnel
- jinja2 : moteur de templates
La structure du projet est la suivante :
project
└── app
├── __init__.py
├── main.py
├── tests.py
├── config.py
├── database.py
├── users
│ ├── __init__.py
│ ├── api.py
│ ├── models.py
│ ├── routers.py
│ └── schemas.py
├── items
│ ├── __init__.py
│ ├── routers.py
│ ├── schemas.py
│ └── templates
│ └── item.html
└── static
└── simple.css
Un programme minimal dans main.py
:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {"message": "Hello World!"}
Pour démarrer le serveur web de développement on lance dans un environnement virtuel après avoir installé les modules fastapi
et uvicorn
:
uvicorn app.main:app --reload
Ensuite le site est accessible via http://localhost:8000/ et la documentation via http://localhost:8000/docs/.
Info : pour inclure les dépendances optionnelles (uvicorn, jinja2…), installer
fastapi[all]
.
Routes, requêtes, réponses…
Les méthodes de requête et les codes de réponse HTTP sont documentés :
Description de l'API et importation des routes dans main.py
:
from .items.routers import router as items_router
app = FastAPI(
title="fastapi-sandbox",
description="A sandbox to play with *FastAPI* framework.",
openapi_tags=[{"name": "items", "description": "Manage *items*."}]
)
app.include_router(items_router)
Définition des routes dans items/routers.py
:
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Request, Body, Cookie, File, Form, Header, Path, Query, UploadFile
from .schemas import Item
router = APIRouter(
prefix="/items",
tags=["items"],
)
@router.get("/{item_id}")
async def read_item(item_id: int, item: Item) -> Item:
"""
The function return type can be defined as follows:
- builtin type
- Pydantic model
- Response
- PlainTextResponse
- HtmlResponse
- JSONResponse
- RedirectResponse
Default response class JSONResponse can be overridden with:
- app = FastAPI(default_response_class=HtmlResponse)
The path decorator's parameter `response_class` define response class:
- @app.get("/items/", response_class=HTMLResponse)
The path decorator's parameter `response_model` define response model:
- @app.get("/portal", response_model=None)
- @app.get("/items/", response_model=list[Item])
- @app.get("/user/", response_model=BaseUser, response_model_exclude_unset=True)
The path decorator's parameter `status_code` define response status code:
- @app.post("/items/", status_code=201)
- @app.post("/items/", status_code=status.HTTP_201_CREATED)
The path decorator's parameters `tags`, `summary` and `description` are used in interactive docs:
- @app.get("/items/", tags=["items"], summary="Create an item", description="Create an item description")
Exception can be defined as follows:
- raise HTTPException(status_code=404, detail="Item not found")
"""
return item
@router.put("/{item_id}")
async def create_item(item_id: int, item: Item, item_query: str):
"""
The function parameters will be recognized as follows:
- If the parameter is also declared in the path, it will be used as a path parameter: `item_id`
- If the parameter is of a builtin type, it will be interpreted as a query parameter: `item_query`
- If the parameter is of a Pydantic model type, it will be interpreted as a request body (json string): `item`
"""
result = {"item_id": item_id, **item.dict()}
if item_query:
result.update({"item_query": item_query})
return result
@router.patch("/{item_id}")
async def update_item(
item_id: Annotated[int, Path(title="Item ID", gt=0, le=100)],
item_query: Annotated[str | None, Query(alias="item-query", title="Query string",
description="Query string for the items to search",
max_length=10, regex=r"^\w+$", deprecated=True)] = None,
item: Item = None,
available: Annotated[bool, Body()] = None,
date: Annotated[datetime, Cookie()] = datetime.now(),
user_agent: Annotated[str | None, Header()] = None):
"""
The function parameters can be defined as follows:
- `Path`: a path parameter
- `Query`: a query parameter
- `Body`: a request body
- `Cookie`: a cookie parameter
- `Header`: a header parameter
See also: [Dependency Injection](https://fastapi.tiangolo.com/tutorial/dependencies/) system (function or class).
"""
result = {"item_id": item_id, "item": item, "available": available, "date": date, "User-Agent": user_agent}
if item_query:
result.update({"item_query": item_query})
return result
@router.post("/file/", tags=["file"])
async def create_file(
token: Annotated[str, Form()],
file: Annotated[bytes | None, File()] = None,
upload_file: UploadFile | None = None):
"""
The function parameters can be defined as follows:
- `Form`: a form parameter
- `File`: a file parameter
- `UploadFile`: a spooled file parameter
You can declare multiple File and Form parameters, but you can't also declare Body fields.
"""
return {"token": token, "file": file, "upload_file": upload_file}
Définition des schémas de validation avec pydantic
dans items/schemas.py
:
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
class Image(BaseModel):
url: HttpUrl
name: str
class Item(BaseModel):
name: str
description: str | None = Field(title="Description", max_length=300)
price: float = Field(lt=100, description="The price must be lesser than 100")
tags: set[str] = set()
image: Image | None = None
@field_validator("price")
def price_must_be_positive(cls, value):
if value <= 0:
raise ValueError(f"The price must be greater than zero, received: {value}")
return value
model_config = ConfigDict(json_schema_extra={
"example": {
"name": "Foo",
"description": "Foo description",
"price": 42.0,
"tags": [
"foo",
"bar",
"baz"
],
"image": {
"url": "http://example.com/foo.jpg",
"name": "The Foo"
}
}
})
Configuration
Le chargement des variables d'environnement est géré par le module pydantic
.
Les variables peuvent être surchargées par un fichier .env
.
Configuration dans config.py
:
from functools import lru_cache
from pydantic import EmailStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "fastapi-sandbox"
admin_email: EmailStr = "admin@example.com"
database_url: str = "sqlite:///db.sqlite3"
model_config = SettingsConfigDict(env_file=".env")
@lru_cache()
def get_settings():
return Settings()
settings = get_settings()
Authentification
L'authentification se base sur OAuth2 et JWT.
Fichiers statiques
Les fichiers statiques sont accessibles dans les templates : url_for('static', path='/style.css')
ou directement à l'adresse http://localhost:8000/static.
Montage d'un répertoire de fichiers statiques dans main.py
:
from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="app/static"), name="static")
Templates
Les modèles HTML sont gérés par le module jinja2
.
Indication du répertoire de templates et rendu de la réponse dans items/routers.py
:
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="app/items/templates")
@router.get("/{item_id}", response_class=HTMLResponse)
async def read_item(request: Request, item_id: int):
return templates.TemplateResponse("item.html", {"request": request, "item_id": item_id})
Définition du template dans items/templates/item.html
:
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Item Details</title>
<link rel="stylesheet" href="{{ url_for('static', path='/simple.css') }}">
</head>
<body>
<h1>Item ID: {{ item_id }}</h1>
</body>
</html>
Tâches de fond
Une tâche de fond peut être utile pour lancer une opération longue comme l'envoi d'un email de notification. Elle est exécutée après avoir retourné la réponse et évite ainsi de bloquer l'utilisateur.
Création d'une tâche de fond dans main.py
:
from fastapi import BackgroundTasks
def write_notification(email: str, message=""):
print(f"notification for {email}: {message}")
@app.post("/send-notification/{email}")
async def send_notification(email: str, background_tasks: BackgroundTasks):
background_tasks.add_task(write_notification, email, message="some notification")
return {"message": "Notification sent in the background"}
Info : pour l'envoi d'emails, voir le module
fastapi-mail
.
Base de données et mapping objet-relationnel
Le mapping objet-relationnel (ORM) est pris en charge par sqlalchemy
.
Importation des routes dans main.py
:
from .users.routers import router as users_router
app.include_router(users_router)
Configuration de la base de données dans database.py
:
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from .config import settings
engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
Définition des routes dans users/routers.py
:
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import database
from . import api, models, schemas
models.Base.metadata.create_all(bind=database.engine)
def get_db():
db = database.SessionLocal()
try:
yield db
finally:
db.close()
router = APIRouter(
prefix="/users",
tags=["users"],
)
@router.post("/", response_model=schemas.User)
def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)):
db_user = api.get_user_by_email(db, email=user.email)
if db_user:
raise HTTPException(status_code=400, detail="Email already registered")
return api.create_user(db=db, user=user)
@router.get("/", response_model=list[schemas.User])
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
users = api.get_users(db, skip=skip, limit=limit)
return users
@router.get("/{user_id}", response_model=schemas.User)
def read_user(user_id: int, db: Session = Depends(get_db)):
db_user = api.get_user(db, user_id=user_id)
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")
return db_user
@router.post("/{user_id}/items/", response_model=schemas.Item)
def create_item_for_user(user_id: int, item: schemas.ItemCreate, db: Session = Depends(get_db)):
return api.create_user_item(db=db, item=item, user_id=user_id)
@router.get("/items/", response_model=list[schemas.Item])
def read_items(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
items = api.get_items(db, skip=skip, limit=limit)
return items
Définition des requêtes dans users/api.py
:
from sqlalchemy.orm import Session
from . import models, schemas
def get_user(db: Session, user_id: int):
return db.query(models.User).filter(models.User.id == user_id).first()
def get_user_by_email(db: Session, email: str):
return db.query(models.User).filter(models.User.email == email).first()
def get_users(db: Session, skip: int = 0, limit: int = 100):
return db.query(models.User).offset(skip).limit(limit).all()
def create_user(db: Session, user: schemas.UserCreate):
fake_hashed_password = user.password + "notreallyhashed"
db_user = models.User(email=user.email, hashed_password=fake_hashed_password)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def get_items(db: Session, skip: int = 0, limit: int = 100):
return db.query(models.Item).offset(skip).limit(limit).all()
def create_user_item(db: Session, item: schemas.ItemCreate, user_id: int):
db_item = models.Item(**item.dict(), owner_id=user_id)
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
Définition des modèles de données dans users/models.py
:
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
items = relationship("Item", back_populates="owner")
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True)
description = Column(String, index=True)
owner_id = Column(Integer, ForeignKey("users.id"))
owner = relationship("User", back_populates="items")
Définition des schémas de validation dans users/schemas.py
:
from pydantic import BaseModel, ConfigDict, EmailStr
class ItemBase(BaseModel):
title: str
description: str | None = None
class ItemCreate(ItemBase):
pass
class Item(ItemBase):
id: int
owner_id: int
model_config = ConfigDict(from_attributes=True)
class UserBase(BaseModel):
email: EmailStr
class UserCreate(UserBase):
password: str
class User(UserBase):
id: int
is_active: bool
items: list[Item] = []
model_config = ConfigDict(from_attributes=True)
Info : pour les migrations de base de données le module
alembic
est indiqué.alembic init alembic vim alembic.ini # sqlalchemy.url alembic revision [--autogenerate] -m "create item table" vim 1975ea83b712_create_item_table.py alembic upgrade head
Tests d'intégration
Les tests sont réalisés avec le module pytest
et le client httpx
:
pytest app/tests.py
Définition des tests dans tests.py
:
from fastapi.testclient import TestClient
from .main import app
client = TestClient(app)
def test_root_endpoint():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello World!"}
def test_items_template():
response = client.get("/items/1")
assert response.status_code == 200
assert response.template.name == 'item.html'
assert "request" in response.context
def test_correct_user():
json = {"email": "myuser@example.com", "password": "mypassword"}
response = client.post("/users/", json=json)
assert response.status_code == 200
def test_wrong_user():
json = {"email": "myuser@example.com"}
response = client.post("/users/", json=json)
assert response.status_code != 200