Skip to content
Merged

Dev #23

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .coverage
Binary file not shown.
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
# DATABASE URL
DATABASE_URL=

# Set to True to expose the interactive docs (/docs, /redoc, /openapi.json)
# to all clients. Whitelisted IPs keep access regardless of this flag.
# Keep False in production.
DEBUG=False

# JWT VARIABLES
# JWT_SECRET is REQUIRED when DEBUG=False. Generate one with:
# python -c "import secrets; print(secrets.token_urlsafe(64))"
JWT_SECRET=
JWT_ALGORITHM=HS256

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11' # Use your project's version
python-version: '3.12' # 3.12+ required for PEP 695 generic syntax

- name: Install dependencies
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/create_pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11' # You can change this to your preferred version
python-version: '3.12' # 3.12+ required for PEP 695 generic syntax
cache: 'pip'

- name: Install Dependencies
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ logs/
.mypy_cache/
.coverage
htmlcov/
.claude/
CLAUDE.md
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ repos:
- id: ruff
args: [ --fix ]

# 4. Type Checking (Mypy)
# 4. Type Checking (Mypy) — v1.7.1 predates PEP 695; needs >= 1.12
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.7.1
rev: v1.15.0
hooks:
- id: mypy
additional_dependencies:
Expand Down
92 changes: 88 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,22 @@ This repository provides a clean and scalable template for building FastAPI appl
- 🗒️ [Predefined Environment Configuration](./app/settings.py) with [Pydantic-Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
- 🛜 Dependency management Setup for [Common Dependencies](./app/dependencies.py)
- `get_db`: Async Database Session Dependency
- `get_current_user`: Async User dependency, Extract user Database with Access Token in request, raises 401 Http Exception if token is not valid
- `get_current_user`: Async User dependency. Extracts the user from the request's access token, and raises a 401 if the token is missing, invalid, blacklisted, or is not an **access** token.

- 👤 Initial [User Model](./app/models/auth.py) and [User Authentication Endpoints](./app/routers/auth.py) with [Unit Tests](./app/routers/tests/test_auth.py)
- 🔐 Full JWT auth flow: signup → email activation, sign-in issuing separate **access** and **refresh** tokens (each tagged with a `type` claim so they are not interchangeable), password reset, profile update, and logout via a Redis token blacklist.
- 🧰 Async [Redis manager](./app/redis_manager.py) (`redis.asyncio`) backing the token blacklist and short-lived one-time codes (activation / password reset).
- 🔒 Docs (`/docs`, `/redoc`, `/openapi.json`) gated behind a `DEBUG` flag **or** an IP allowlist — hidden with a 404 otherwise.
- 🚦 Per-endpoint rate limiting on the auth routes via [`slowapi`](./app/limiter.py), plus per-account lockout after repeated bad codes (brute-force protection on login and code endpoints).
- 🩺 `/health` readiness probe and a startup connectivity check for the database and Redis.
- 📝 [Predefined Logging](./app/logger.py) Configuration
- ⚙️ Unit Test Configuration with Pytest (With Async Support)
- ⏺️ [Alembic Data Migration](./alembic) Configuration and [alembic.ini](alembic.ini)


## Getting Started
> Requires **Python 3.12+** (the codebase uses PEP 695 generic syntax).

In order to get started with the FastAPI Project, follow the following steps
- [ ] Activate Project Python Virtual Environment
```bash
Expand All @@ -31,6 +38,8 @@ In order to get started with the FastAPI Project, follow the following steps
- [ ] Create an .env file from the .env.example file and provide values for missing environment variables
- 1. Update the DATABASE_URL to point at an accessible DATABASE server
- 2. Update the MAIL_CONFIG section to include mail server credentials
- 3. Set `DEBUG=True` for local development (also exposes the interactive docs — see Quirks)
- [ ] Ensure a **Redis** server is running and reachable at `REDIS_HOST`/`REDIS_PORT` (defaults to `localhost:6379`). The auth flows and the test suite talk to a real Redis instance.
- [ ] Install ```make``` if you do not already have it and run the command ```make run-local``` to start you local server
- [ ] Apply Initial Database Migration for Ensure Database Connection string is valid
```bash
Expand All @@ -47,8 +56,52 @@ In order to get started with the FastAPI Project, follow the following steps
make run-local
```

## NOTES
- After making Changes to your Model(s) in the models/ directory, ensure the Model class is Imported in the __init__ module of the models directory, this way, the configured alembic for your project can pick up models changes for Migrations
## Architecture

The template is **async-first** and organizes each feature across four layers. When you add a feature, follow the same shape the `auth` feature uses:

| Layer | Responsibility |
| --- | --- |
| [`routers/`](./app/routers) | HTTP endpoints, dependency wiring, and `response_model`. Kept **thin** — no business logic. |
| [`services/`](./app/services) | Business logic: DB queries, token/password/email orchestration, Redis access. |
| [`schemas/`](./app/schemas) | Pydantic request/response models — all validation lives here. |
| [`models/`](./app/models) | SQLAlchemy ORM models (persistence). All inherit `AbstractBase` → UUID PK + `date_created`/`date_updated`. |

Request flow: a router aggregates into [`app/api_router.py`](./app/api_router.py) under the `/v1` prefix, which is mounted in [`app/main.py`](./app/main.py). `main.py` also assembles the middleware stack (CORS → GZip → TrustedHost → docs gate → request logging), a `slowapi` rate limiter, and uniform JSON exception handlers.

Supporting singletons: [`redis_manager`](./app/redis_manager.py) (async Redis for the token blacklist and one-time codes) and [`send_mail`](./app/mailer.py) (Jinja templates from `app/templates/`, always dispatched via FastAPI `BackgroundTasks`).

### Paginated responses

[`app/schemas/__init__.py`](./app/schemas/__init__.py) provides a reusable generic envelope for list endpoints, `PaginatedResponse[T]`, so paginated payloads share one consistent shape:

| Field | Meaning |
| --- | --- |
| `total_results` | total rows matching the query |
| `current_page` | 1-based page number returned |
| `total_pages` | total number of pages |
| `per_page` | page size used |
| `results` | the page of items, typed as `list[T]` |

Parameterize it with the item schema and use it as the endpoint's `response_model`:

```python
from app.schemas import PaginatedResponse
from app.schemas.auth import UserModel

@router.get("/users", response_model=PaginatedResponse[UserModel])
async def list_users(db: DBDep, page: int = 1, per_page: int = 20):
# ... run the query, collect `users` and `total_results` ...
return PaginatedResponse[UserModel](
total_results=total_results,
current_page=page,
total_pages=-(-total_results // per_page), # ceiling division
per_page=per_page,
results=users,
)
```

> The model uses PEP 695 generic syntax (`class PaginatedResponse[T]`), which needs **Python 3.12+** and **mypy ≥ 1.12** — the CI workflows and the pre-commit `mypy` pin are set accordingly. On an older toolchain you'd see `Name "T" is not defined`; bump the versions (or fall back to the classic `Generic[T]` + `TypeVar` form).

## Project Structure

Expand Down Expand Up @@ -81,6 +134,25 @@ fastapi-project-structure/
└── requirements.txt
```

## Quirks & Gotchas

Things that are easy to trip over when building on this template:

- **Alembic only sees models imported in [`app/models/__init__.py`](./app/models/__init__.py).** After adding a model, import it there (and add it to `__all__`) *before* running `alembic revision --autogenerate` — otherwise the migration silently misses your table.
- **Redis is required and its client is async.** Auth flows (logout blacklist, activation/reset codes) and the test suite hit a real Redis server. Every `redis_manager` call is a coroutine — `await` it. The test suite closes the connection pool after each test (autouse fixture in [`conftest.py`](./conftest.py)) because `pytest-asyncio` gives each test its own event loop; a shared `redis.asyncio` pool would otherwise reuse a closed-loop socket and raise `Event loop is closed`.
- **Docs are gated by `DEBUG` OR the IP allowlist.** The [`AllowAuthorizedDocAccess`](./app/middlewares.py) middleware serves `/docs`, `/redoc`, and `/openapi.json` only when `settings.DEBUG` is true **or** the client IP is in `allowed_ips` (default `127.0.0.1`); otherwise it returns a 404 that hides their existence. Note this middleware runs **before** `TrustedHostMiddleware`, so a request that clears the docs gate must still use a host listed in `main.py`'s `allowed_hosts`.
- **Access and refresh tokens are not interchangeable.** Each carries a `type` claim (`access` / `refresh`). `get_current_user` rejects anything that isn't an access token; the `refresh_token` endpoint rejects anything that isn't a refresh token.
- **Refresh tokens are single-use (rotated).** Each call to `/refresh_token` blacklists the presented refresh token and returns a fresh access **and** refresh token, so a leaked refresh token is usable at most once.
- **Logout is global.** It blacklists the presented token(s) for their remaining lifetime *and* bumps a per-user token version in Redis, so **every** token issued before the logout is invalidated across all devices. Tokens carry a `ver` claim checked on each request; pass the refresh token in the logout body (`{"refresh_token": "..."}`) to blacklist it explicitly too.
- **Repeated bad codes lock the account.** After `MAX_CODE_ATTEMPTS` (default 5) wrong activation/reset codes, that account is locked for `CODE_LOCKOUT_SECONDS`; a successful attempt clears the counter.
- **`/health` and startup checks.** `/health` returns 503 if the DB or Redis is unreachable. On boot the app pings both; in production (`DEBUG=False`) it refuses to start if either is down, in `DEBUG` it only logs.
- **Sign-in requires a verified email.** `signin_user` returns `403 Email not verified` until activation flips `is_verified`. In tests, `UserFactory` builds verified users; use `create_user`/an unverified user to exercise the rejection.
- **Auth endpoints are rate-limited** via `slowapi` (`@limiter.limit` in [`app/routers/auth.py`](./app/routers/auth.py), registered in [`app/main.py`](./app/main.py)). Limits are **Redis-backed** ([`app/limiter.py`](./app/limiter.py)) so they hold across workers/replicas. The limiter is **disabled in the test suite** (`conftest.py`) since the counter is shared across tests — enable it per-test (with a unique client key) to assert 429s.
- **Access tokens are short-lived; sign secrets are enforced in production.** Access tokens default to 15 minutes (`ACCESS_TOKEN_LIFESPAN_MIN`), refresh tokens to 28 days (`REFRESH_TOKEN_LIFESPAN_DAYS`). With `DEBUG=False`, an empty `JWT_SECRET` makes the app refuse to start.
- **One-time codes are single-use and cryptographically random.** Activation and password-reset codes come from `secrets` and are deleted from Redis on successful use, so they can't be replayed.
- **`app/main.py` contains `{{ project_name }}`-style placeholders** (title/version/summary). These are template placeholders meant to be filled in per project, not bugs.
- **Mail sends with `VALIDATE_CERTS=True`.** If your dev SMTP uses a self-signed certificate, adjust the `ConnectionConfig` in [`app/mailer.py`](./app/mailer.py).

## How to Download Complete Project Structure from Github

1. **Clone the repository:**
Expand Down Expand Up @@ -123,8 +195,20 @@ pytest -s app/routers/tests/test_auth.py

## Environment Variables

Copy `.env.example` to `.env` and update the values as needed.
Copy `.env.example` to `.env` and update the values as needed. Notable keys:

- `DATABASE_URL` (required) — async driver expected, e.g. `postgresql+asyncpg://...`
- `DEBUG` (default `False`) — when `True`, exposes the interactive docs to all clients (see Quirks)
- `REDIS_HOST` / `REDIS_PORT` (default `localhost` / `6379`)
- `JWT_SECRET` — signing key; **required when `DEBUG=False`** (the app refuses to boot with an empty secret in production). `JWT_ALGORITHM` defaults to `HS256`.
- `ACCESS_TOKEN_LIFESPAN_MIN` (default `15`, **minutes**) / `REFRESH_TOKEN_LIFESPAN_DAYS` (default `28`, days)
- `MAIL_*` — SMTP credentials used by the mailer


## Upgrading an Existing Project
Backporting these changes into a project scaffolded from an older version of the
template? Follow [UPGRADING.md](./UPGRADING.md) — it isolates each change so you
can apply them independently, and keeps rate limiting an optional, skippable step.

## Contributing
Contributions are welcome! Please open issues or submit pull requests.
Empty file added TODO.md
Empty file.
Loading
Loading