3.5 KiB
CLAUDE.md
Guidance for working in this repo. Read alongside README.md (user-facing setup).
What this is
A FastAPI skeleton serving both a JSON API (/api/*) and server-rendered
HTML via Jinja2 + HTMX (/, /admin/*). Async SQLAlchemy 2.0 ORM, JWT auth
with refresh-token revocation, and RBAC (users → roles → permissions).
Commands
uv sync --extra dev # install (incl. dev tools)
uv run python -m scripts.seed # seed perms/roles/superuser (idempotent)
uv run uvicorn app.main:app --reload # dev server on :8000
uv run pytest # tests (in-memory SQLite)
uv run ruff check . [--fix] # lint
uv run alembic upgrade head # apply migrations
uv run alembic revision --autogenerate -m "msg" # new migration after model changes
docker compose up --build # app + Postgres, auto-seeded
Architecture / layering
Request → router (app/api/ JSON or app/web/ HTML) → app/crud/ (data access)
→ app/models/. Schemas in app/schemas/, cross-cutting DI in app/deps.py,
token logic in app/services/auth.py. Keep route handlers thin; put DB logic in
crud/, not in routers.
Conventions that matter
- Everything async. Engine, sessions, CRUD, routes, tests. Use
await; never add a sync DB call.get_db(inapp/core/database.py) yields the request session. - Relationships use
lazy="selectin"(User.roles,Role.permissions) so attributes are safe to read after the session closes — required under async. Preserve this when adding relationships that templates/schemas read. - Config is
app/core/config.py(pydantic-settings). Add new settings there; they're auto-read from env vars (uppercased field name) or.env. Env wins. - RBAC: guard API routes with
Depends(require_permission("resource:action")); guard web routes withweb_require(...)inapp/web/admin.py. Superusers bypass all checks (User.is_superuser/User.has_permission). Add new permission strings toscripts/seed.py'sPERMISSIONS/ROLES. - Auth, two transports: API uses
Authorization: Bearer; the HTMX UI uses an httpOnly cookie (settings.cookie_name).app/deps.pyreads both. Web auth failures raiseNotAuthenticated, handled inmain.pyas a redirect to/login. - Refresh tokens are stored server-side (hash +
jti+revoked) in therefresh_tokenstable.services/auth.rotate_refresh_tokenrevokes-then-reissues. Note: JWT payloadexpis a tz-awaredatetime(PyJWT only epoch-encodes inside the token) — pass it straight to the model, don'tfromtimestampit.
Database
One DATABASE_URL drives SQLite (sqlite+aiosqlite://, the default) or Postgres
(postgresql+asyncpg://). AUTO_CREATE_TABLES=true calls Base.metadata.create_all
on startup for dev convenience; production should use Alembic instead. init_db()
imports app.models inside the function to avoid a circular import.
Tests
tests/conftest.py builds an in-memory SQLite engine with StaticPool, creates
tables, and overrides get_db. Fixtures: client (httpx ASGI), seeded
(perms+roles), make_user. asyncio_mode = "auto" — write async def test_*
without decorators.
When extending
Add a feature → model in models/ (+ register in models/__init__.py), schema in
schemas/, CRUD in crud/, routes in api/ and/or web/, then a migration. If it
needs access control, add the permission to the seed and guard the routes.