Cheatsheet FastAPI
Framework Python moderno e rápido para APIs com type hints
FastAPI
Installation and Setup
Install FastAPI
pip install fastapi uvicorn[standard] # create main.py uvicorn main:app --reload
fastapi is the framework. uvicorn is the ASGI server. --reload reloads the code automatically in development. The standard extra includes websockets and httptools.
Configuration with Settings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
secret_key: str
debug: bool = False
class Config:
env_file = ".env"
settings = Settings()BaseSettings reads environment variables automatically. env_file loads from a .env file. Values with defaults are optional. Ideal for separating config from code.
First Application
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {"msg": "Hello FastAPI!"}FastAPI() creates the application instance. @app.get() registers a GET endpoint. The returned dictionary is serialized to JSON automatically. Type hints generate automatic validation.
API Metadata
app = FastAPI(
title="My API",
description="Item management API",
version="1.0.0",
contact={"name": "Dev Team"},
)title and description appear in the documentation. version controls the API version. contact adds contact info. This metadata enriches the Swagger UI.
Automatic Documentation
# Swagger UI: # http://localhost:8000/docs # ReDoc: # http://localhost:8000/redoc # OpenAPI JSON: # http://localhost:8000/openapi.json
FastAPI generates interactive documentation automatically. /docs shows Swagger UI with tests. /redoc shows formatted ReDoc. /openapi.json is the complete OpenAPI schema.
Tags for Organization
tags_metadata = [
{"name": "users", "description": "User operations"},
{"name": "items", "description": "Item management"},
]
app = FastAPI(openapi_tags=tags_metadata)
@app.get("/users", tags=["users"])
def list_users(): ...tags group endpoints in the documentation. openapi_tags adds descriptions to the tags. Each endpoint can have multiple tags. Makes Swagger UI navigation easier.
Project Structure
app/
main.py
models.py
schemas.py
database.py
dependencies.py
routers/
users.py
items.pymain.py is the entry point. schemas.py defines Pydantic models. models.py defines SQLAlchemy tables. routers/ organizes endpoints by module. dependencies.py centralizes dependencies.
Virtual Environment
python -m venv .venv source .venv/bin/activate # Linux/Mac .venv\Scripts\activate # Windows pip install fastapi uvicorn pip freeze > requirements.txt
venv isolates project dependencies. activate activates the virtual environment. pip freeze generates the dependencies file. Always use virtual environments per project.
Routes and Endpoints
HTTP Methods
@app.get("/items")
def list_items(): ...
@app.post("/items")
def create(): ...
@app.put("/items/{id}")
def update(id: int): ...
@app.delete("/items/{id}")
def delete(id: int): ...Each decorator maps an HTTP verb. GET for reading, POST for creation. PUT updates fully, PATCH partially. DELETE removes resources.
Status Codes
from fastapi import status
@app.post("/items", status_code=status.HTTP_201_CREATED)
def create(): ...
@app.delete("/items/{id}", status_code=204)
def delete(id: int): ...
# status.HTTP_404_NOT_FOUND
# status.HTTP_403_FORBIDDENstatus_code sets the HTTP response code. 201 for created resources. 204 for deletion with no body. The status module has named constants for all codes.
Redirect and Status Code
from fastapi.responses import RedirectResponse
@app.get("/old")
def old():
return RedirectResponse(url="/new", status_code=301)
@app.get("/items/{id}", status_code=200)
def get_item(id: int, response: Response):
response.headers["X-Custom"] = "value"
return {"id": id}RedirectResponse with 301 is a permanent redirect. The Response parameter allows adding custom headers. Headers are useful for rate-limiting and cache-control.
Path Parameters
@app.get("/users/{user_id}")
def get_user(user_id: int):
return {"id": user_id}
# /users/42 → user_id = 42
# /users/abc → 422 errorParameters between {} in the route are captured. The int type-hint validates and converts automatically. Invalid values return a 422 error with details. No manual parsing needed.
Custom Responses
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse
return JSONResponse(content={"error": "Not found"}, status_code=404)
return RedirectResponse(url="/login")
return HTMLResponse(content="<h1>Hello</h1>")JSONResponse lets you control status and headers. RedirectResponse performs a redirect. HTMLResponse returns raw HTML. By default, dictionaries become JSON automatically.
Query Parameters
@app.get("/items")
def list_items(skip: int = 0, limit: int = 10):
return items[skip : skip + limit]
# /items?skip=0&limit=5Parameters with a default value become query params. skip and limit are optional in the URL. Without a default, they would be required. FastAPI validates types automatically.
Response Model
class UserOut(BaseModel):
id: int
name: str
email: str
@app.get("/users/{id}", response_model=UserOut)
def get_user(id: int):
return user_with_password # password is filteredresponse_model controls the returned fields. Fields outside the model are removed automatically. Protects sensitive data like passwords. Generates precise response documentation.
APIRouter
from fastapi import APIRouter
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/")
def list_users(): ...
@router.get("/{user_id}")
def get(user_id: int): ...
# main.py:
app.include_router(router)APIRouter organizes routes into separate modules. prefix adds a base path to all routes. include_router() registers it in the main app. Essential for large projects.
Route Naming and Docs
@app.get(
"/items",
summary="List items",
description="Returns all active items with pagination.",
response_description="List of items",
deprecated=True,
)
def list_items(): ...summary is the short title in Swagger. description adds long details. response_description documents the response. deprecated=True marks it the obsolete in the docs.
Parameters and Validation
Body with Pydantic
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
active: bool = True
@app.post("/items")
def create(item: Item):
return itemParameters typed the BaseModel are read from the JSON body. FastAPI validates the fields automatically. Errors return 422 with details. No manual request parsing needed.
Form Data
from fastapi import Form
@app.post("/login")
def login(
username: str = Form(),
password: str = Form(),
):
return {"user": username}Form() reads HTML form data (application/x-www-form-urlencoded). Requires python-multipart installed. Cannot be combined with a JSON body in the same endpoint.
Enum Parameters
from enum import Enum
class OrderBy(str, Enum):
name = "name"
price = "price"
date = "date"
@app.get("/items")
def list_items(order: OrderBy = OrderBy.name):
return {"ordered_by": order.value}Enum restricts accepted values. Inheriting from str allows direct comparison. Invalid values return 422. The documentation shows the available values the a dropdown.
Query Validation
from fastapi import Query
@app.get("/items")
def list_items(
q: str | None = Query(None, min_length=3, max_length=50),
page: int = Query(1, ge=1),
):
...Query() adds validations to query parameters. min_length and max_length limit strings. ge (greater or equal) validates numbers. None the default makes it optional.
File Uploads
from fastapi import UploadFile, File
@app.post("/upload")
async def upload(file: UploadFile = File(...)):
content = await file.read()
return {
"name": file.filename,
"type": file.content_type,
"size": len(content),
}UploadFile handles uploads with streaming. File(...) makes it required. filename and content_type provide metadata. read() is async for large files.
Path Validation
from fastapi import Path
@app.get("/items/{item_id}")
def get_item(
item_id: int = Path(ge=1, title="Item ID"),
):
...Path() adds constraints to path parameters. ge=1 ensures a positive value. title appears in the documentation. Useful for IDs that must always be positive.
Multiple Files
from fastapi import UploadFile, File
@app.post("/uploads")
async def uploads(files: list[UploadFile] = File(...)):
return [
{"name": f.filename, "size": f.size}
for f in files
]list[UploadFile] accepts multiple files. The client sends them with the same field name. size gives the size without reading everything. Ideal for galleries and bulk imports.
Headers and Cookies
from fastapi import Header, Cookie
@app.get("/info")
def info(
user_agent: str = Header(),
x_token: str | None = Header(None),
session: str | None = Cookie(None),
):
return {"agent": user_agent}Header() reads HTTP headers. Hyphens become underscores automatically (x-token → x_token). Cookie() reads request cookies. Both support validation and defaults.
Optional Parameters
@app.get("/items")
def list_items(
q: str | None = None,
category: str | None = None,
max_price: float | None = None,
):
results = items
if q:
results = [i for i in results if q in i["name"]]
return resultsstr | None = None makes the parameter optional. Python 3.10+ union type syntax. If omitted in the URL, it receives None. Enables dynamic filters without multiple endpoints.
Pydantic e Models
Basic Model
from pydantic import BaseModel
class User(BaseModel):
name: str
email: str
age: int | None = None
active: bool = TrueBaseModel defines schemas with automatic validation. Type hints define the expected type. Fields with defaults are optional. int | None accepts integer or null.
Model Validators
from pydantic import BaseModel, model_validator
class Registration(BaseModel):
password: str
confirm: str
@model_validator(mode="after")
def check_match(self):
if self.password != self.confirm:
raise ValueError("Passwords do not match")
return self@model_validator validates multiple fields together. mode="after" runs after individual validation. Accesses self with all fields. Ideal for confirmations and dependencies between fields.
Field and Constraints
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1, max_length=200)
price: float = Field(gt=0, description="Price in euros")
tags: list[str] = []
quantity: int = Field(default=0, ge=0)Field() adds validations and metadata. gt=0 ensures greater than zero. min_length validates string length. description appears in the OpenAPI documentation.
Serialization
user = User(name="Anna", email="ana@site.com") # to dictionary: data = user.model_dump() # to JSON: json_str = user.model_dump_json() # from JSON: user2 = User.model_validate_json(json_str)
model_dump() converts to a Python dictionary. model_dump_json() serializes to a JSON string. model_validate_json() parses JSON into a model. They replace the old .dict() and .json().
Custom Validators
from pydantic import BaseModel, field_validator
class User(BaseModel):
email: str
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
if "@" not in v:
raise ValueError("invalid email")
return v.lower()@field_validator creates custom validation. @classmethod is required. ValueError generates a 422 error. The returned value replaces the original (normalization).
Model Config
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True,
str_to_lower=True,
frozen=True,
)
name: str
email: strConfigDict configures model behavior. str_strip_whitespace removes spaces. frozen=True makes it immutable. str_to_lower normalizes to lowercase.
Nested Models
class Address(BaseModel):
street: str
city: str
postal_code: str
class User(BaseModel):
name: str
addresses: list[Address] = []
primary: Address | None = NoneModels can contain other models. list[Address] validates arrays of objects. Address | None accepts an object or null. Validation is recursive and automatic.
Computed Fields
from pydantic import BaseModel, computed_field
class Rect(BaseModel):
width: float
height: float
@computed_field
@property
def area(self) -> float:
return self.width * self.height@computed_field adds calculated fields to serialization. It appears in the response JSON automatically. @property makes it read-only. Useful for totals and derived values.
Database
SQLAlchemy Setup
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
SQLALCHEMY_URL = "sqlite:///./app.db"
engine = create_engine(SQLALCHEMY_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase):
passcreate_engine() creates the DB connection. sessionmaker is a session factory. DeclarativeBase is the base for models. check_same_thread is required for SQLite.
Relationships
from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
user: Mapped["User"] = relationship(back_populates="posts")
class User(Base):
posts: Mapped[list["Post"]] = relationship(back_populates="user")ForeignKey creates the link in the DB. relationship() defines access in Python. back_populates connects both sides. Mapped[list] for one-to-many relationships.
Pydantic + SQLAlchemy
from pydantic import BaseModel, ConfigDict
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
email: str
@app.get("/users/{id}", response_model=UserOut)
def get_user(id: int, db: Session = Depends(get_db)):
return db.query(User).filter_by(id=id).first()from_attributes=True allows creating Pydantic models from ORM objects. Replaces the old orm_mode. The response_model filters fields automatically. Bridge between DB and API.
SQLAlchemy Model
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.orm import Mapped, mapped_column
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(unique=True)
active: Mapped[bool] = mapped_column(default=True)mapped_column() is the modern SQLAlchemy 2.0 API. Mapped[type] defines the Python type. primary_key marks the primary key. unique prevents duplicates.
Migrations (Alembic)
pip install alembic alembic init migrations # generate automatic migration: alembic revision --autogenerate -m "create users" # apply: alembic upgrade head # revert: alembic downgrade -1
Alembic manages schema migrations. --autogenerate compares models with the DB. upgrade head applies all pending ones. downgrade -1 reverts the last one. Essential for schema evolution.
Session Dependency
from sqlalchemy.orm import Session
from fastapi import Depends
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get("/users")
def list_users(db: Session = Depends(get_db)):
return db.query(User).all()Depends(get_db) injects the session into each request. yield keeps the session open during the endpoint. finally guarantees closing. FastAPI dependency injection pattern.
Advanced Queries
from sqlalchemy import select, func # with select (SQLAlchemy 2.0): stmt = select(User).where(User.active == True).order_by(User.name) users = db.scalars(stmt).all() # aggregations: total = db.scalar(select(func.count(User.id))) average = db.scalar(select(func.avg(User.age)))
select() is the modern query API. scalars() returns objects directly. func.count() and func.avg() perform aggregations. where() chains conditions.
CRUD Operations
# Create user = User(name="Anna", email="ana@site.com") db.add(user) db.commit() db.refresh(user) # Read users = db.query(User).filter(User.active == True).all() user = db.query(User).filter_by(id=1).first() # Delete db.delete(user) db.commit()
add() + commit() inserts into the DB. refresh() reloads with generated data (id). filter() uses expressions, filter_by() uses kwargs. first() returns None if it does not exist.
Async Database
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
AsyncSession = async_sessionmaker(engine)
async def get_db():
async with AsyncSession() as session:
yield session
@app.get("/users")
async def list_users(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User))
return result.scalars().all()create_async_engine creates an asynchronous engine. async_sessionmaker is an async session factory. await db.execute() runs queries without blocking. Requires an async driver like asyncpg.
Authentication and Security
OAuth2 + JWT Setup
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@app.get("/profile")
def profile(token: str = Depends(oauth2_scheme)):
return {"token": token}OAuth2PasswordBearer extracts the token from the Authorization header. tokenUrl points to the login endpoint. Swagger UI gets an "Authorize" button automatically. Depends injects the token.
Login Endpoint
from fastapi.security import OAuth2PasswordRequestForm
@app.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends()):
user = authenticate(form.username, form.password)
if not user:
raise HTTPException(401, "Invalid credentials")
token = create_token({"sub": str(user.id)})
return {"access_token": token, "token_type": "bearer"}OAuth2PasswordRequestForm expects username and password the form data. The response must have access_token and token_type. Compatible with the Swagger OAuth2 flow.
Generate JWT Token
from jose import jwt
from datetime import datetime, timedelta
SECRET_KEY = "super-secret"
ALGORITHM = "HS256"
def create_token(data: dict, expires_min: int = 30):
payload = data.copy()
payload["exp"] = datetime.utcnow() + timedelta(minutes=expires_min)
return jwt.encode(payload, SECRET_KEY, somethingrithm=ALGORITHM)jwt.encode() creates the signed token. exp sets the expiration (required). HS256 is the signing somethingrithm. SECRET_KEY should be in an environment variable.
Auth Dependencies
from fastapi import Depends, HTTPException
def admin_required(user=Depends(get_current_user)):
if user.role != "admin":
raise HTTPException(403, "Access denied")
return user
@app.delete("/users/{id}", dependencies=[Depends(admin_required)])
def delete_user(id: int): ...Dependencies chain: admin_required depends on get_current_user. dependencies=[] in the decorator protects without injecting. Return 403 for insufficient permissions. Reusable pattern.
Validate Token
from jose import JWTError, jwt
from fastapi import HTTPException
def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, SECRET_KEY, somethingrithms=[ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401)
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
return user_idjwt.decode() validates signature and expiration. sub is the subject (user ID). JWTError catches invalid or expired tokens. Return 401 for invalid credentials.
CORS
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)CORSMiddleware allows requests from other domains. allow_origins lists allowed domains. allow_credentials permits cookies. In production, never use ["*"] with credentials.
Password Hashing
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return pwd_context.verify(password, hashed)CryptContext manages hashing with automatic salt. bcrypt is the recommended scheme. hash() generates an irreversible hash. verify() compares input against the stored hash. Never store passwords in plain text.
Rate Limiting
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.get("/api")
@limiter.limit("10/minute")
def api(request: Request):
return {"data": "..."}slowapi adds rate limiting. key_func identifies the client (by IP). limit() sets a maximum per period. Returns 429 when exceeded. Essential for public APIs.
Async e Background
Async Endpoint
@app.get("/data")
async def get_data():
result = await fetch_external()
return resultasync def defines an asynchronous endpoint. await waits for I/O operations without blocking. The server handles other requests while waiting. Use for DB, HTTP calls and files.
Lifespan Events
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup: open connections, load cache
print("Starting...")
yield
# shutdown: close connections, clean up resources
print("Shutting down...")
app = FastAPI(lifespan=lifespan)lifespan replaces the old on_startup/on_shutdown events. Code before yield runs at startup. Code after runs at shutdown. Ideal for initializing resources.
async vs sync
# async: for I/O (DB, HTTP, files)
@app.get("/a")
async def endpoint_a():
data = await external_api()
return data
# sync: for CPU (calculations, processing)
@app.get("/b")
def endpoint_b():
result = heavy_computation()
return resultUse async for I/O operations (network, DB). Use regular def for CPU-bound work. Sync endpoints run in a threadpool automatically. Mixing both is perfectly valid.
Concurrency with asyncio
import asyncio
@app.get("/parallel")
async def parallel():
results = await asyncio.gather(
fetch_api_1(),
fetch_api_2(),
fetch_api_3(),
)
return {"results": results}asyncio.gather() runs multiple tasks in parallel. All run concurrently in the event loop. Faster than sequential calls. Ideal for aggregating data from multiple APIs.
Background Tasks
from fastapi import BackgroundTasks
def send_email(to: str, msg: str):
# slow task (SMTP)
...
@app.post("/register")
def register(bg: BackgroundTasks):
bg.add_task(send_email, "ana@site.com", "Welcome!")
return {"msg": "Registered"}BackgroundTasks runs after sending the response. add_task() schedules the function with arguments. The client does not wait for the task to finish. Ideal for emails, notifications and logs.
Streaming Response
from fastapi.responses import StreamingResponse
async def generate_data():
for i in range(1000):
yield f"line {i}\n"
@app.get("/stream")
def stream():
return StreamingResponse(
generate_data(),
media_type="text/plain",
)StreamingResponse sends data progressively. The yield generator produces chunks. media_type sets the Content-Type. Ideal for large files, SSE and downloads.
httpx (async HTTP)
import httpx
@app.get("/external")
async def external():
async with httpx.AsyncClient() as client:
response = await client.get("https://api.example.com/data")
return response.json()httpx.AsyncClient makes asynchronous HTTP requests. async with manages the connection automatically. await client.get() does not block the event loop. Modern replacement for requests when going async.
WebSockets
from fastapi import WebSocket
@app.websocket("/ws")
async def websocket(ws: WebSocket):
await ws.accept()
while True:
data = await ws.receive_text()
await ws.send_text(f"Echo: {data}")@app.websocket creates a WebSocket endpoint. accept() establishes the connection. receive_text() and send_text() exchange messages. Persistent connection for real time (chat, notifications).
Advanced and Deploy
Middleware
import time
@app.middleware("http")
async def request_time(request, call_next):
start = time.time()
response = await call_next(request)
duration = time.time() - start
response.headers["X-Process-Time"] = str(duration)
return response@app.middleware("http") intercepts all requests. call_next() passes to the next handler. Allows adding headers, logging or metrics. Runs before and after the endpoint.
Tests with Override
from fastapi.testclient import TestClient
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)dependency_overrides replaces dependencies in tests. Swap the real DB for a test one. The dictionary maps original → substitute. Clean up with .clear() after the tests.
Best Practices
# 1. Type hints everywhere # 2. Pydantic for validation (not manual) # 3. Dependencies for shared logic # 4. APIRouter to organize # 5. response_model to control output
Always use type hints — they generate free validation. Prefer Pydantic over manual validation. Depends() eliminates repetition. APIRouter scales the project. response_model protects sensitive data.
Error Handling
from fastapi import HTTPException
@app.get("/items/{id}")
def get_item(id: int):
item = find_item(id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return item
# custom handler:
@app.exception_handler(ValueError)
async def value_error_handler(request, exc):
return JSONResponse(status_code=400, content={"error": str(exc)})HTTPException raises HTTP errors with detail. exception_handler catches custom exceptions globally. detail appears in the JSON response. Always use appropriate status codes.
Deploy with Uvicorn
# Production: uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 # with Gunicorn: gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker # variables: # WEB_CONCURRENCY=4 # PORT=8000
--workers 4 creates multiple processes. Gunicorn the a process manager in production. UvicornWorker combines Gunicorn with ASGI. Rule: workers = 2 × CPU colors + 1.
Reusable Dependencies
from fastapi import Depends
class Pagination:
def __init__(self, page: int = 1, size: int = 10):
self.offset = (page - 1) * size
self.limit = size
@app.get("/items")
def list_items(pg: Pagination = Depends()):
return db.query(Item).offset(pg.offset).limit(pg.limit).all()Classes with __init__ work the dependencies. Depends() without arguments uses the defaults. Parameters become query params automatically. Reuse across multiple endpoints.
Docker
FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]
python:3.12-slim is the lightweight base image. --no-cache-dir reduces size. EXPOSE documents the port. --host 0.0.0.0 accepts external connections. Multi-stage build to optimize.
Testing with TestClient
from fastapi.testclient import TestClient
client = TestClient(app)
def test_root():
r = client.get("/")
assert r.status_code == 200
assert r.json() == {"msg": "Hello!"}
def test_create_item():
r = client.post("/items", json={"name": "Test", "price": 9.99})
assert r.status_code == 201TestClient simulates HTTP requests without a server. json= sends a JSON body. assert validates status and response. Compatible with pytest. Run with pytest tests/.
Structured Logging
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@app.get("/items")
def list_items():
logger.info("Listing items", extra={"user": "ana"})
return itemsThe stdlib logging is the standard way. extra adds structured context. level=INFO filters by severity. In production use JSON logging for aggregation (ELK, Grafana).