Perfect π― β hereβs a complete, beginner-friendly, blog-style walkthrough of your FastAPI + Docker Compose project, written like a professional developer article you could publish on Medium, Dev.to, or your own website. Itβs structured for education, SEO, and clarity, with code snippets, commands, and clear sections.
By Sreeprakash Neelakantan AI, Cloud, and Automation Consultant | NVIDIA-Certified Generative AI Specialist
If youβre a Python developer looking to build modern, high-performance APIs, FastAPI is one of the best frameworks out there. Itβs fast, type-safe, easy to learn, and built on top of Starlette and Pydantic, giving you automatic validation, interactive documentation, and blazing speed.
In this tutorial, youβll build a fully functional FastAPI project that runs with Docker Compose, includes multiple endpoints, file upload/download, background tasks, headers, cookies, authentication, and even WebSockets β all in a single setup.
By the end, youβll be able to:
β
Create and run a FastAPI app inside Docker
β
Explore automatic API docs in your browser
β
Interact with the app using curl and Swagger UI
β
Understand middleware, routers, and models
β
Extend it for your own projects easily
Before we start, make sure you have:
Thatβs all!
Hereβs how your project will look:
fastapi-docker-compose-tutorial/
βββ Dockerfile
βββ docker-compose.yml
βββ requirements.txt
βββ README.md
βββ app/
βββ main.py
βββ deps.py
βββ models.py
βββ middleware.py
βββ routers/
βββ health.py
βββ items.py
βββ users.py
βββ misc.py
βββ files.pyEach component plays a specific role:
| File | Purpose |
|---|---|
main.py |
Entry point β connects all routers |
routers/ |
Contains all endpoint definitions |
models.py |
Defines Pydantic data models |
middleware.py |
Adds timing and logging middleware |
deps.py |
Handles dependencies like API keys and pagination |
Dockerfile |
Builds the app into a Docker image |
docker-compose.yml |
Defines how containers run |
requirements.txt |
Lists dependencies |
Letβs go through the core files that make this work.
from fastapi import FastAPI
from .routers import health, items, users, misc, files
from .middleware import TimingMiddleware
app = FastAPI(title="FastAPI Beginner Tutorial", version="1.0.0")
# Add timing middleware
app.add_middleware(TimingMiddleware)
# Register routers
app.include_router(health.router)
app.include_router(misc.router)
app.include_router(items.router)
app.include_router(users.router)
app.include_router(files.router)This file wires together all your routes and adds a custom middleware that measures response time.
import time
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start = time.time()
response = await call_next(request)
elapsed = (time.time() - start) * 1000
response.headers["X-Process-Time-ms"] = f"{elapsed:.2f}"
return responseAdds an X-Process-Time-ms header showing how long each request took.
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]services:
api:
build: .
container_name: fastapi_tutorial_api
ports:
- "8000:8000"
volumes:
- ./app:/app/app:rw
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reloadThis allows live code reload when editing files locally.
fastapi==0.115.2
uvicorn[standard]==0.31.1
pydantic==2.9.2
python-multipart==0.0.12
email-validator==2.2.0Open your terminal and run:
docker compose up --buildThen visit these URLs:
You should see FastAPIβs auto-generated documentation.
Here are a few ready-to-try commands:
curl -s http://localhost:8000/health# Create
curl -s -X POST http://localhost:8000/items -H "Content-Type: application/json" \
-d '{"name":"USB Hub","price":1299,"tags":["usb","hub"]}'
# List
curl -s http://localhost:8000/items
# Get
curl -s http://localhost:8000/items/1
# Update
curl -s -X PUT http://localhost:8000/items/1 -H "Content-Type: application/json" \
-d '{"name":"USB Hub Pro","price":1499,"tags":["usb","hub","pro"]}'
# Delete
curl -s -X DELETE http://localhost:8000/items/1curl -s -X POST http://localhost:8000/users/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"secret"}'curl -s -F "file=@README.md" http://localhost:8000/files/uploadcurl -s "http://localhost:8000/background/send-email?to=demo@example.com"wscat -c ws://localhost:8000/ws/echo
# Type: hello
# Response: echo: helloVisit http://localhost:8000/docs. You can βTry it outβ right from your browser β no external tools required.
Swagger automatically documents:
| Concept | Explanation |
|---|---|
| Routers | Organize endpoints into logical modules (e.g., /items, /users) |
| Models | Define input/output schemas using Pydantic |
| Middleware | Modify requests/responses globally (e.g., add headers, timing) |
| Dependencies | Reusable logic like pagination or API key verification |
| Background Tasks | Run async jobs without blocking requests |
| WebSockets | Enable real-time communication |
| Swagger UI | Auto-generated docs and testing interface |
Once you understand this template, you can easily add:
pytest and httpx| Step | Action |
|---|---|
| 1 | Create project structure |
| 2 | Write FastAPI app and routers |
| 3 | Add middleware and models |
| 4 | Set up Docker and Compose |
| 5 | Run with docker compose up |
| 6 | Test endpoints via Swagger and curl |
| 7 | Extend and scale |
By containerizing your FastAPI app early, you:
This small setup mirrors how real-world production APIs are structured β clean, modular, and easily testable.
FastAPI + Docker Compose is a perfect combo for modern backend development. It gives you:
Whether youβre building a startup MVP, an internal tool, or a full SaaS backend, this foundation will scale with you.
Clone it, run it, and explore:
Then tweak it β add authentication, connect a database, or build your own mini API service.
Author: Sreeprakash Neelakantan Founder & Managing Director, Schogini Systems Private Limited NVIDIA-Certified Associate in Generative AI LLMs
Helping small businesses and professionals adopt AI, automation, and modern development stacks for real-world growth.
Perfect β letβs take this project to the next level π
Hereβs Part 2 of the blog series, expanding your FastAPI + Docker Compose foundation to include a real database (SQLite) and JWT-based authentication β exactly how production-grade APIs handle users securely.
By Sreeprakash Neelakantan NVIDIA-Certified AI Professional | Founder of Schogini Systems Private Limited
Weβll extend the previous project with:
This makes your FastAPI app a true backend skeleton β ready for real users.
Update your requirements.txt to include:
fastapi==0.115.2
uvicorn[standard]==0.31.1
pydantic==2.9.2
python-multipart==0.0.12
email-validator==2.2.0
SQLAlchemy==2.0.36
passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0Rebuild your container:
docker compose build
docker compose upapp/db.py)from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
SQLALCHEMY_DATABASE_URL = "sqlite:///./app.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependency for endpoints
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()app/models_db.py)from sqlalchemy import Column, Integer, String
from .db 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)Initialize the database on startup (edit main.py):
from .db import Base, engine
from .models_db import User
@app.on_event("startup")
def on_startup():
Base.metadata.create_all(bind=engine)app/auth.py)from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from .db import get_db
from .models_db import User
SECRET_KEY = "supersecretkey-change-this"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
def verify_password(plain, hashed): return pwd_context.verify(plain, hashed)
def get_password_hash(pw): return pwd_context.hash(pw)
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
cred_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise cred_exc
except JWTError:
raise cred_exc
user = db.query(User).filter(User.email == email).first()
if user is None:
raise cred_exc
return userapp/routers/auth.py)from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from ..db import get_db
from ..models_db import User
from ..auth import create_access_token, get_password_hash, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register")
def register(email: str, password: str, db: Session = Depends(get_db)):
if db.query(User).filter(User.email == email).first():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(email=email, hashed_password=get_password_hash(password))
db.add(user)
db.commit()
db.refresh(user)
return {"message": "User registered", "email": user.email}
@router.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(data={"sub": user.email})
return {"access_token": token, "token_type": "bearer"}app/routers/secure.py)from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..models_db import User
router = APIRouter(prefix="/secure", tags=["secure"])
@router.get("/me")
def read_users_me(current_user: User = Depends(get_current_user)):
return {"email": current_user.email}Include it in main.py:
from .routers import secure, auth
app.include_router(auth.router)
app.include_router(secure.router)curl -X POST "http://localhost:8000/auth/register?email=demo@example.com&password=secret"curl -X POST -d "username=demo@example.com&password=secret" \
-H "Content-Type: application/x-www-form-urlencoded" \
http://localhost:8000/auth/tokenResponse:
{"access_token": "eyJhbGciOi...", "token_type": "bearer"}curl -H "Authorization: Bearer eyJhbGciOi..." \
http://localhost:8000/secure/meYouβll get your email back only if the token is valid.
sub=email)get_current_user to validate token| Concept | Explanation |
|---|---|
| SQLAlchemy | ORM for managing database tables via Python classes |
| Passlib | Safely hash passwords with bcrypt |
| JWT | Compact, signed token that authenticates API calls |
| OAuth2PasswordBearer | FastAPI helper to extract Authorization header tokens |
| Dependency Injection | Makes auth logic reusable via Depends() |
| Step | Action |
|---|---|
| 1 | Added SQLAlchemy, Passlib, and JWT dependencies |
| 2 | Configured SQLite database |
| 3 | Created User model |
| 4 | Built JWT auth helpers |
| 5 | Added register and login routes |
| 6 | Protected API routes with Bearer token |
| 7 | Tested everything with curl and Swagger UI |
Youβve now transformed a simple FastAPI demo into a complete backend foundation with:
From here, you can plug in a frontend (React, Next.js, or Svelte), add more models, and deploy on AWS or DigitalOcean.
Author: Sreeprakash Neelakantan NVIDIA-Certified Associate in Generative AI LLMs | Founder & MD, Schogini Systems Private Limited
Helping small businesses and professionals leverage AI and automation for 24Γ7 growth.
Perfectβhereβs Part 3 of your series. Weβll Dockerize PostgreSQL, switch your project to Async SQLAlchemy, and add a pytest test-suite (with async HTTP testing). Itβs practical, end-to-end, and slots neatly onto your Part 1/2 codebase.
By Sreeprakash Neelakantan NVIDIA-Certified AI | Founder & MD, Schogini Systems Private Limited
asyncpg)httpx.AsyncClient + pytest-asyncioYouβll get a production-flavored stack while staying beginner-friendly.
New/updated dependencies (requirements.txt):
fastapi==0.115.2
uvicorn[standard]==0.31.1
pydantic==2.9.2
python-multipart==0.0.12
email-validator==2.2.0
SQLAlchemy==2.0.36
passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0
asyncpg==0.29.0
pytest==8.3.3
pytest-asyncio==0.24.0
httpx==0.27.2
asgi-lifespan==2.1.0Rebuild after editing:
docker compose buildUpdate your docker-compose.yml:
services:
api:
build: .
container_name: fastapi_tutorial_api
ports:
- "8000:8000"
volumes:
- ./app:/app/app:rw
environment:
- DATABASE_URL=postgresql+asyncpg://app:app@db:5432/app
- LOG_LEVEL=info
- JWT_SECRET=change-me
depends_on:
db:
condition: service_healthy
command: >
sh -c "uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
db:
image: postgres:16-alpine
container_name: fastapi_tutorial_db
environment:
- POSTGRES_DB=app
- POSTGRES_USER=app
- POSTGRES_PASSWORD=app
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app -d app']
interval: 3s
timeout: 3s
retries: 10
volumes:
pgdata:Why
depends_on.healthcheck? It ensures the API waits until Postgres is ready.
Bring both up:
docker compose up --buildCreate app/db_async.py:
# app/db_async.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://app:app@db:5432/app")
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
class Base(DeclarativeBase):
pass
async def get_db():
async with SessionLocal() as session:
yield sessionReplace your Part-2 sync models with async-friendly SQLAlchemy models.
app/models_db.py
# app/models_db.py
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Integer, Float
from .db_async import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
email: Mapped[str] = mapped_column(String, unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String)
class Item(Base):
__tablename__ = "items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String, index=True)
price: Mapped[float] = mapped_column(Float)
tags: Mapped[str] = mapped_column(String, default="") # comma-separated for demoWe keep items simple:
tagsas a comma-separated string (beginner-friendly). You can normalize later.
Edit app/main.py to create tables with async engine:
# app/main.py
from fastapi import FastAPI
from .routers import health, items, users, misc, files
from .middleware import TimingMiddleware
from .db_async import Base, engine
from .models_db import User, Item
app = FastAPI(title="FastAPI Beginner Tutorial", version="3.0.0")
app.add_middleware(TimingMiddleware)
@app.on_event("startup")
async def on_startup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# routers
app.include_router(health.router)
app.include_router(misc.router)
app.include_router(items.router) # we'll update to async DB
app.include_router(users.router) # we'll update to async DB
app.include_router(files.router)app/auth.py (only changes: import async Session and query with await):
# app/auth.py
from datetime import datetime, timedelta
from jose import jwt, JWTError
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from .db_async import get_db
from .models_db import User
import os
SECRET_KEY = os.getenv("JWT_SECRET", "change-me")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
def verify_password(plain, hashed): return pwd_context.verify(plain, hashed)
def get_password_hash(pw): return pwd_context.hash(pw)
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)):
cred_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise cred_exc
except JWTError:
raise cred_exc
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user is None:
raise cred_exc
return userapp/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..db_async import get_db
from ..models_db import User
from ..auth import create_access_token, get_password_hash, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register")
async def register(email: str, password: str, db: AsyncSession = Depends(get_db)):
exists = await db.execute(select(User).where(User.email == email))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(email=email, hashed_password=get_password_hash(password))
db.add(user)
await db.commit()
await db.refresh(user)
return {"message": "User registered", "email": user.email}
@router.post("/token")
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
res = await db.execute(select(User).where(User.email == form.username))
user = res.scalar_one_or_none()
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token({"sub": user.email})
return {"access_token": token, "token_type": "bearer"}Register in main.py if not already:
from .routers import auth, secure
app.include_router(auth.router)
app.include_router(secure.router)app/routers/secure.py
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..models_db import User
router = APIRouter(prefix="/secure", tags=["secure"])
@router.get("/me")
async def read_me(current_user: User = Depends(get_current_user)):
return {"email": current_user.email}app/routers/items.py (now DB-backed; replaces in-memory):
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from typing import Optional, List
from ..db_async import get_db
from ..models_db import Item
from ..models import ItemIn, Item as ItemOut
router = APIRouter(prefix="/items", tags=["items"])
def _tags_to_str(tags: list[str]) -> str:
return ",".join(tags)
def _tags_to_list(s: str) -> list[str]:
return [t for t in (s or "").split(",") if t]
@router.post("", response_model=ItemOut, status_code=201)
async def create_item(payload: ItemIn, db: AsyncSession = Depends(get_db)):
obj = Item(name=payload.name, price=payload.price, tags=_tags_to_str(payload.tags))
db.add(obj)
await db.commit()
await db.refresh(obj)
return ItemOut(id=obj.id, name=obj.name, price=obj.price, tags=_tags_to_list(obj.tags))
@router.get("", response_model=List[ItemOut])
async def list_items(skip: int = 0, limit: int = 10, tag: Optional[str] = None, db: AsyncSession = Depends(get_db)):
stmt = select(Item).offset(skip).limit(limit)
if tag:
# naive filter: tag substring match
stmt = select(Item).where(Item.tags.like(f"%{tag}%")).offset(skip).limit(limit)
res = await db.execute(stmt)
rows = res.scalars().all()
return [ItemOut(id=i.id, name=i.name, price=i.price, tags=_tags_to_list(i.tags)) for i in rows]
@router.get("/{item_id}", response_model=ItemOut)
async def get_item(item_id: int, db: AsyncSession = Depends(get_db)):
res = await db.execute(select(Item).where(Item.id == item_id))
obj = res.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Item not found")
return ItemOut(id=obj.id, name=obj.name, price=obj.price, tags=_tags_to_list(obj.tags))
@router.put("/{item_id}", response_model=ItemOut)
async def update_item(item_id: int, payload: ItemIn, db: AsyncSession = Depends(get_db)):
res = await db.execute(select(Item).where(Item.id == item_id))
obj = res.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Item not found")
obj.name = payload.name
obj.price = payload.price
obj.tags = _tags_to_str(payload.tags)
await db.commit()
await db.refresh(obj)
return ItemOut(id=obj.id, name=obj.name, price=obj.price, tags=_tags_to_list(obj.tags))
@router.delete("/{item_id}", status_code=204)
async def delete_item(item_id: int, db: AsyncSession = Depends(get_db)):
res = await db.execute(select(Item).where(Item.id == item_id))
obj = res.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Item not found")
await db.execute(delete(Item).where(Item.id == item_id))
await db.commit()
return NoneCreate a tests folder:
app/
...
tests/
conftest.py
test_auth.py
test_items.pytests/conftest.py β start the FastAPI app in-process and use an async client.
import os
import pytest
import asyncio
from httpx import AsyncClient
from asgi_lifespan import LifespanManager
from app.main import app
# Use a separate DB for tests if you like, e.g. "app_test"
# os.environ["DATABASE_URL"] = "postgresql+asyncpg://app:app@db:5432/app_test"
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop()
yield loop
@pytest.fixture(scope="session", async_fixture=True)
async def test_client():
async with LifespanManager(app):
async with AsyncClient(app=app, base_url="http://testserver") as ac:
yield actests/test_auth.py
import pytest
@pytest.mark.asyncio
async def test_register_and_login(test_client):
# register
r = await test_client.post("/auth/register", params={"email": "t@e.com", "password": "secret"})
assert r.status_code in (200, 400) # allow "already registered" on re-run
# login
r = await test_client.post("/auth/token", data={"username": "t@e.com", "password": "secret"})
assert r.status_code == 200
token = r.json()["access_token"]
assert token
# /secure/me
r = await test_client.get("/secure/me", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
assert r.json()["email"] == "t@e.com"tests/test_items.py
import pytest
@pytest.mark.asyncio
async def test_items_crud(test_client):
payload = {"name": "Pen", "price": 9.99, "tags": ["stationery"]}
r = await test_client.post("/items", json=payload)
assert r.status_code == 201
item = r.json()
item_id = item["id"]
r = await test_client.get(f"/items/{item_id}")
assert r.status_code == 200
assert r.json()["name"] == "Pen"
r = await test_client.put(f"/items/{item_id}", json={"name":"Pen Pro","price":12.5,"tags":["stationery","pro"]})
assert r.status_code == 200
assert r.json()["price"] == 12.5
r = await test_client.get("/items?tag=pro")
assert r.status_code == 200
assert any(i["id"] == item_id for i in r.json())
r = await test_client.delete(f"/items/{item_id}")
assert r.status_code == 204
r = await test_client.get(f"/items/{item_id}")
assert r.status_code == 404Run tests (inside the api container or on host with env pointing to db):
# If running inside the API container shell:
docker compose exec api bash -lc "pytest -q"
# Or from host (tests use in-process app; DB must be running):
pytest -qTip: For completely isolated test DBs, create a
app_testdatabase and setDATABASE_URLinconftest.pybefore importing the app (shown as a comment).
# Health
curl -s http://localhost:8000/health
# Register + Login
curl -s -X POST "http://localhost:8000/auth/register?email=demo@example.com&password=secret"
curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" \
-d "username=demo@example.com&password=secret" \
http://localhost:8000/auth/token
# Use token
TOKEN="paste_token_here"
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/secure/me
# Items CRUD
curl -s -X POST http://localhost:8000/items -H "Content-Type: application/json" \
-d '{"name":"Book","price":199.0,"tags":["reading"]}'
curl -s http://localhost:8000/itemscreate_async_engine, async_sessionmaker, select()httpx.AsyncClient + pytest-asyncio + asgi-lifespan