diff --git a/.branch-cleanup b/.branch-cleanup
new file mode 100644
index 0000000..f0b519f
--- /dev/null
+++ b/.branch-cleanup
@@ -0,0 +1 @@
+This branch has been superseded by master and can be safely deleted.
diff --git a/.github/workflows/auto-tag-release.yml b/.github/workflows/auto-tag-release.yml
index 41ced50..dd30055 100644
--- a/.github/workflows/auto-tag-release.yml
+++ b/.github/workflows/auto-tag-release.yml
@@ -3,16 +3,19 @@ name: Auto Tag Release
on:
pull_request:
types: [closed]
- branches: [master, stage, dev]
+ branches: [master, stage]
permissions:
contents: write
jobs:
tag:
+ # Always fire on master merges (defaults to patch bump).
+ # Fire on stage only when a release:* label is present.
if: >
github.event.pull_request.merged == true &&
(
+ github.event.pull_request.base.ref == 'master' ||
contains(github.event.pull_request.labels.*.name, 'release:patch') ||
contains(github.event.pull_request.labels.*.name, 'release:minor') ||
contains(github.event.pull_request.labels.*.name, 'release:major')
@@ -40,8 +43,6 @@ jobs:
TARGET_BRANCH="${{ github.event.pull_request.base.ref }}"
if [ "$TARGET_BRANCH" = "stage" ]; then
BASE_PREFIX="stage-v"
- elif [ "$TARGET_BRANCH" = "dev" ]; then
- BASE_PREFIX="dev-v"
fi
LAST_TAG="$(git tag -l "${BASE_PREFIX}*" --sort=-v:refname | sed -n '1p')"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e8bec51..8fb9a04 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: CI
on:
push:
- branches: [master, stage, dev]
+ branches: [master, stage]
pull_request:
- branches: [master, stage, dev]
+ branches: [master, stage]
permissions:
contents: read
@@ -50,6 +50,6 @@ jobs:
with:
files: coverage.xml
flags: python${{ matrix.python-version }}
- fail_ci_if_error: true
+ fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4cebc9b..a8cb3ef 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,9 +3,7 @@ name: Release
on:
push:
branches:
- - master
- stage
- - dev
tags:
- "v*"
@@ -15,56 +13,12 @@ permissions:
packages: write
jobs:
- # ── Publish channel builds to TestPyPI on branch merges ───────────────
+ # ── Publish pre-release builds to TestPyPI on stage merges ───────────
# Channel mapping:
- # - dev -> 0.0.0.devN
- # - stage -> 0.0.0aN
- # - master -> 0.0.0rcN
- # Stable PyPI releases remain tag-based (vX.Y.Z).
- publish-testpypi-dev:
- if: github.ref == 'refs/heads/dev'
- runs-on: ubuntu-latest
- environment: testpypi
- permissions:
- contents: read
- steps:
- - name: Verify TestPyPI token is configured
- run: |
- if [ -z "${{ secrets.TEST_PYPI_API_TOKEN }}" ]; then
- echo "::error::Missing secret TEST_PYPI_API_TOKEN (Settings → Secrets and variables → Actions)."
- exit 1
- fi
-
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - uses: actions/setup-python@v5
- with:
- python-version: "3.13"
-
- - name: Install build tools
- run: pip install build
-
- - name: Build channel package (dev)
- env:
- SETUPTOOLS_SCM_PRETEND_VERSION: 0.0.0.dev${{ github.run_number }}
- run: python -m build
-
- - name: Upload package artifact (dev channel)
- uses: actions/upload-artifact@v4
- with:
- name: dist-dev
- path: dist/
-
- - name: Publish to TestPyPI
- uses: pypa/gh-action-pypi-publish@release/v1
- with:
- repository-url: https://test.pypi.org/legacy/
- user: __token__
- password: ${{ secrets.TEST_PYPI_API_TOKEN }}
- skip-existing: true
-
+ # - stage -> 0.0.0aN (TestPyPI pre-release)
+ # - master -> vX.Y.Z (PyPI stable, via auto-tag from auto-tag-release.yml)
+ # Stable PyPI releases are tag-based (vX.Y.Z); master branch push triggers
+ # the auto-tag workflow which creates the tag that drives this pipeline.
publish-testpypi-stage:
if: github.ref == 'refs/heads/stage'
runs-on: ubuntu-latest
@@ -109,51 +63,6 @@ jobs:
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
skip-existing: true
- publish-testpypi-master:
- if: github.ref == 'refs/heads/master'
- runs-on: ubuntu-latest
- environment: testpypi
- permissions:
- contents: read
- steps:
- - name: Verify TestPyPI token is configured
- run: |
- if [ -z "${{ secrets.TEST_PYPI_API_TOKEN }}" ]; then
- echo "::error::Missing secret TEST_PYPI_API_TOKEN (Settings → Secrets and variables → Actions)."
- echo "Create an API token on TestPyPI for the `faster-api-web` project and add it as TEST_PYPI_API_TOKEN."
- exit 1
- fi
-
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - uses: actions/setup-python@v5
- with:
- python-version: "3.13"
-
- - name: Install build tools
- run: pip install build
-
- - name: Build channel package (master)
- env:
- SETUPTOOLS_SCM_PRETEND_VERSION: 0.0.0rc${{ github.run_number }}
- run: python -m build
-
- - name: Upload package artifact (master channel)
- uses: actions/upload-artifact@v4
- with:
- name: dist-master
- path: dist/
-
- - name: Publish to TestPyPI
- uses: pypa/gh-action-pypi-publish@release/v1
- with:
- repository-url: https://test.pypi.org/legacy/
- user: __token__
- password: ${{ secrets.TEST_PYPI_API_TOKEN }}
- skip-existing: true
-
# ── Gate: only release from master ──────────────────────────────────
check-branch:
if: startsWith(github.ref, 'refs/tags/v')
@@ -277,7 +186,7 @@ jobs:
# ── Step 5: Create GitHub Release with artifacts ───────────────────
github-release:
- needs: [build, publish-pypi, publish-docker]
+ needs: [build, publish-pypi]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 17b0e21..4badb4a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -7,42 +7,38 @@ Thank you for your interest in contributing! This document explains how we work.
## Branch Model
```
- dev/your-feature ──PR──▶ stage ──PR──▶ master
- (yours) (integration) (production)
+ feature/your-feature ──PR──▶ stage ──PR──▶ master
+ (yours) (integration) (production)
```
| Branch | Purpose | Who can push directly |
|---|---|---|
| `master` | Production-ready code, releases | **Nobody** — merge from `stage` via PR only |
-| `stage` | Integration / pre-release | **Nobody** — merge from `dev` via PR only |
-| `dev` / `dev/*` | Feature integration and branch-level previews | Maintainers via PR only |
+| `stage` | Integration / pre-release | **Nobody** — merge from feature branches via PR only |
For **security-sensitive** reports, use the process in [SECURITY.md](SECURITY.md) instead of a public issue.
### Rules
-1. **Never push directly to `master`, `stage`, or `dev`.**
+1. **Never push directly to `master` or `stage`.**
2. Create your branch from **`stage`** (never from an outdated `master` without syncing):
```bash
git checkout stage
git pull origin stage
- git checkout -b dev/my-feature
+ git checkout -b feature/my-feature
```
3. Commit with **clear messages** (what changed and why in one line; optional scope prefix, e.g. `docs:`, `bench:`).
-4. Open a **pull request from your branch → `dev`** for first integration.
+4. Open a **pull request from your branch → `stage`** for integration.
5. CI (tests on Python 3.10–3.13 + benchmarks on PRs) must pass.
6. At least **one approval** is required before merging to `stage`, when reviewers are available.
-7. Periodically, maintainers promote `dev` → `stage` and `stage` → `master`.
+7. Periodically, maintainers promote `stage` → `master`.
8. **Stable releases** are **git tags** on `master` (`v0.2.0`, …), which trigger PyPI + Docker + GitHub Releases. The **PyPI version is taken from the tag** (see `hatch-vcs` in `pyproject.toml`) — **do not** rely on editing a static `version =` in `pyproject.toml` for releases.
9. To automate semver tagging, add exactly one PR label: `release:patch`, `release:minor`, or `release:major`. On merge:
- to `master`: creates `vX.Y.Z`
- to `stage`: creates `stage-vX.Y.Z`
- - to `dev`: creates `dev-vX.Y.Z`
10. Channel builds publish automatically:
- - `dev` push: TestPyPI `0.0.0.devN`
- - `stage` push: TestPyPI `0.0.0aN`
- - `master` push: TestPyPI `0.0.0rcN`
- - `vX.Y.Z` tag: stable PyPI + Docker + GitHub Release
+ - `stage` push: TestPyPI `0.0.0aN`
+ - `vX.Y.Z` tag: stable PyPI + Docker + GitHub Release
---
@@ -87,7 +83,7 @@ Before opening a PR, verify:
## What Happens on Your PR
-When you open a PR to `dev`, `stage`, or `master`, two workflows run automatically:
+When you open a PR to `stage` or `master`, two workflows run automatically:
1. **CI** — Tests on Python 3.10, 3.11, 3.12, 3.13 with coverage
2. **Benchmark** — Runs framework benchmarks and posts a comparison comment on the PR
@@ -116,12 +112,6 @@ Configure these in **Settings → Rules → Rulesets**:
- Block direct pushes
- Require pull request
- Require status checks: `CI`, `Benchmark`
- - Restrict allowed source branch for PRs to `dev` only
-3. **Dev ruleset**
- - Target: `dev`
- - Block direct pushes
- - Require pull request (even for maintainers, if desired)
- - Require status checks: `CI`, `Benchmark`
Also set **Settings → Actions → General** so workflows can create commits/tags when needed (`Read and write permissions` for `GITHUB_TOKEN`).
@@ -149,11 +139,10 @@ Also set **Settings → Actions → General** so workflows can create commits/ta
### Notes about branch channel versions
- TestPyPI/PyPI require valid PEP 440 versions.
-- Human-readable suffixes like `-stage` or `-dev` are not valid upload versions on PyPI.
+- Human-readable suffixes like `-stage` are not valid upload versions on PyPI.
- Channel identity is represented with valid semver segments:
- - `dev` channel: `.devN`
- `stage` channel: `aN`
- - `master` preview channel: `rcN`
+ - stable: `vX.Y.Z` (tag-based)
---
diff --git a/FasterAPI/__init__.py b/FasterAPI/__init__.py
index da3cbc4..9d2f403 100644
--- a/FasterAPI/__init__.py
+++ b/FasterAPI/__init__.py
@@ -11,6 +11,7 @@
from ._version import get_version
__version__ = get_version()
+__author__ = "Eshwar Chandra Vidhyasagar Thedla"
from .app import Faster
from .background import BackgroundTask, BackgroundTasks
diff --git a/README.md b/README.md
index 5a47f0e..59a7e5c 100644
--- a/README.md
+++ b/README.md
@@ -490,13 +490,13 @@ uvicorn examples.full_crud_app:app --reload
| Endpoint | FasterAPI | FastAPI | Speedup |
|---|---|---|---|
-| `GET /health` | **467 req/s** | 514 req/s | **0.91x** |
-| `GET /users/{id}` | **512 req/s** | 549 req/s | **0.93x** |
-| `POST /users` | **464 req/s** | 473 req/s | **0.98x** |
+| `GET /health` | **478 req/s** | 490 req/s | **0.98x** |
+| `GET /users/{id}` | **478 req/s** | 535 req/s | **0.89x** |
+| `POST /users` | **452 req/s** | 466 req/s | **0.97x** |
| Routing | Radix ops/s | Regex ops/s | Speedup |
|---|---|---|---|
-| 100-route lookup | **971,712** | 100,759 | **9.6x** |
+| 100-route lookup | **944,886** | 94,498 | **10.0x** |
_This block is updated automatically on pushes to `dev`, `stage`, and `master`._
diff --git a/benchmarks/baseline.json b/benchmarks/baseline.json
index 807260f..4808b87 100644
--- a/benchmarks/baseline.json
+++ b/benchmarks/baseline.json
@@ -1,9 +1,9 @@
{
"_comment": "CI fails if measured speedups drop below these floors (Ubuntu runner). Update when intentional perf work lands.",
"min_speedup_vs_fastapi": {
- "health": 3.8,
- "users_get": 4.5,
- "users_post": 3.8
+ "health": 3.5,
+ "users_get": 4.0,
+ "users_post": 3.5
},
- "min_radix_speedup_vs_regex": 4.0
+ "min_radix_speedup_vs_regex": 3.5
}
diff --git a/benchmarks/update_readme_benchmarks.py b/benchmarks/update_readme_benchmarks.py
index 955e80a..9fb2b58 100644
--- a/benchmarks/update_readme_benchmarks.py
+++ b/benchmarks/update_readme_benchmarks.py
@@ -4,7 +4,6 @@
import re
from pathlib import Path
-
README_PATH = Path("README.md")
BENCH_JSON = Path("bench_results.json")
ROUTING_JSON = Path("routing_results.json")
diff --git a/docs/advanced/async-tests.md b/docs/advanced/async-tests.md
new file mode 100644
index 0000000..1efc2e3
--- /dev/null
+++ b/docs/advanced/async-tests.md
@@ -0,0 +1,176 @@
+# Async Tests
+
+FasterAPI handlers are async coroutines. Use **pytest-asyncio** to test them
+directly without going through HTTP, or combine with `TestClient` for full
+integration testing.
+
+## Installation
+
+```bash
+pip install pytest pytest-asyncio httpx
+pip install faster-api-web[test]
+```
+
+## Configuring pytest-asyncio
+
+Add to `pyproject.toml`:
+
+```toml
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+```
+
+Or per-file with a marker:
+
+```python
+import pytest
+pytestmark = pytest.mark.asyncio
+```
+
+## Testing handler functions directly
+
+Call the handler as a coroutine without HTTP overhead:
+
+```python
+# main.py
+import msgspec
+from FasterAPI import Faster
+
+app = Faster()
+
+
+class Item(msgspec.Struct):
+ name: str
+ price: float
+
+
+@app.get("/items/{item_id}")
+async def get_item(item_id: int) -> Item:
+ return Item(id=item_id, name="Widget", price=9.99)
+```
+
+```python
+# tests/test_handlers.py
+import pytest
+from main import get_item
+
+
+@pytest.mark.asyncio
+async def test_get_item():
+ result = await get_item(item_id=1)
+ assert result.name == "Widget"
+ assert result.price == 9.99
+```
+
+## Async fixtures
+
+```python
+import pytest_asyncio
+
+
+@pytest_asyncio.fixture
+async def db_session():
+ session = await open_test_db()
+ yield session
+ await session.close()
+
+
+@pytest.mark.asyncio
+async def test_with_db(db_session):
+ rows = await db_session.fetch("SELECT 1")
+ assert rows is not None
+```
+
+## Async TestClient
+
+Use `httpx.AsyncClient` with the ASGI transport for fully async integration tests:
+
+```python
+import pytest
+import httpx
+from main import app
+
+
+@pytest.mark.asyncio
+async def test_list_items_async():
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="http://test",
+ ) as client:
+ r = await client.get("/items")
+ assert r.status_code == 200
+```
+
+## Concurrent requests
+
+Test that concurrent requests behave correctly:
+
+```python
+import asyncio
+
+
+@pytest.mark.asyncio
+async def test_concurrent_requests():
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="http://test",
+ ) as client:
+ tasks = [client.get("/items") for _ in range(10)]
+ responses = await asyncio.gather(*tasks)
+ assert all(r.status_code == 200 for r in responses)
+```
+
+## Testing WebSockets
+
+```python
+from FasterAPI import TestClient
+from main import app
+
+
+def test_websocket():
+ client = TestClient(app)
+ with client.websocket_connect("/ws") as ws:
+ ws.send_text("hello")
+ data = ws.receive_text()
+ assert data == "Echo: hello"
+```
+
+## Testing lifespan events
+
+```python
+@pytest.fixture
+def app_with_lifespan():
+ from FasterAPI import Faster
+ test_app = Faster()
+ state = {}
+
+ @test_app.on_startup
+ async def startup():
+ state["db"] = "connected"
+
+ @test_app.get("/status")
+ async def status():
+ return {"db": state.get("db")}
+
+ return test_app, state
+
+
+def test_startup_ran(app_with_lifespan):
+ test_app, state = app_with_lifespan
+ with TestClient(test_app) as client:
+ r = client.get("/status")
+ assert r.json()["db"] == "connected"
+```
+
+## Coverage
+
+Run with coverage:
+
+```bash
+pytest --cov=. --cov-report=term-missing
+```
+
+## Next steps
+
+- [Testing with Overrides](testing-overrides.md) — swap dependencies in tests.
+- [Dependencies](../tutorial/dependencies.md) — understand what you're testing.
diff --git a/docs/advanced/behind-proxy.md b/docs/advanced/behind-proxy.md
new file mode 100644
index 0000000..2f0d35a
--- /dev/null
+++ b/docs/advanced/behind-proxy.md
@@ -0,0 +1,132 @@
+# Behind a Proxy
+
+When FasterAPI runs behind a reverse proxy (Nginx, Traefik, AWS ALB, …), the
+original client IP and URL scheme may be rewritten. Configure your app to trust
+forwarded headers.
+
+## The problem
+
+Without configuration, `request.client` returns the **proxy's IP**, not the browser's.
+Similarly, `request.url.scheme` may say `http` even though the client used `https`.
+
+## Trusting `X-Forwarded-For`
+
+```python
+from FasterAPI import Faster, Request
+
+app = Faster()
+
+
+@app.get("/ip")
+async def client_ip(request: Request):
+ forwarded_for = request.headers.get("x-forwarded-for")
+ if forwarded_for:
+ # X-Forwarded-For can be a comma-separated list: client, proxy1, proxy2
+ real_ip = forwarded_for.split(",")[0].strip()
+ else:
+ real_ip = request.client[0] if request.client else None
+ return {"ip": real_ip}
+```
+
+!!! warning
+ Only trust `X-Forwarded-For` if your app is **guaranteed to be behind a proxy**
+ that strips or overwrites this header. Otherwise a client can spoof it.
+
+## Root path
+
+If your API is mounted at a sub-path (e.g. `/api/v1`), tell the server via the
+`root_path` ASGI extension. Pass it to uvicorn:
+
+```bash
+uvicorn main:app --root-path /api/v1
+```
+
+Or set in a middleware:
+
+```python
+from FasterAPI import BaseHTTPMiddleware
+
+
+class RootPathMiddleware(BaseHTTPMiddleware):
+ def __init__(self, app, root_path: str = ""):
+ super().__init__(app)
+ self.root_path = root_path
+
+ async def dispatch(self, scope, receive, send):
+ if scope["type"] in ("http", "websocket"):
+ scope = {**scope, "root_path": self.root_path}
+ await self.app(scope, receive, send)
+
+
+app.add_middleware(RootPathMiddleware, root_path="/api/v1")
+```
+
+This makes Swagger UI generate correct server URLs.
+
+## Nginx configuration
+
+```nginx
+upstream fasterapi {
+ server 127.0.0.1:8000;
+}
+
+server {
+ listen 443 ssl;
+ server_name api.example.com;
+
+ ssl_certificate /etc/ssl/certs/api.pem;
+ ssl_certificate_key /etc/ssl/private/api.key;
+
+ location /api/ {
+ proxy_pass http://fasterapi/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_redirect off;
+ }
+}
+```
+
+## Traefik configuration (Docker labels)
+
+```yaml
+services:
+ api:
+ image: my-fasterapi-app
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.api.rule=PathPrefix(`/api`)"
+ - "traefik.http.routers.api.entrypoints=websecure"
+ - "traefik.http.middlewares.strip-api.stripprefix.prefixes=/api"
+ - "traefik.http.routers.api.middlewares=strip-api"
+```
+
+## Trusted host validation
+
+Only allow requests with specific `Host` headers to prevent host-header injection:
+
+```python
+from FasterAPI import TrustedHostMiddleware
+
+app.add_middleware(
+ TrustedHostMiddleware,
+ allowed_hosts=["api.example.com", "*.example.com"],
+)
+```
+
+## HTTPS redirect
+
+Redirect all plain HTTP traffic to HTTPS at the application layer (usually handled
+by the proxy, but useful as a safety net):
+
+```python
+from FasterAPI import HTTPSRedirectMiddleware
+
+app.add_middleware(HTTPSRedirectMiddleware)
+```
+
+## Next steps
+
+- [Deployment: Nginx/Traefik](../deployment/nginx-traefik.md) — full proxy configs.
+- [Deployment: Docker](../deployment/docker.md) — containerised deployments.
diff --git a/docs/advanced/bigger-apps.md b/docs/advanced/bigger-apps.md
new file mode 100644
index 0000000..bc1bbd3
--- /dev/null
+++ b/docs/advanced/bigger-apps.md
@@ -0,0 +1,195 @@
+# Bigger Applications
+
+As your project grows, organise routes into separate modules and routers. This page
+shows a recommended layout for a medium-to-large FasterAPI project.
+
+## Recommended project layout
+
+```
+myproject/
+├── main.py # Faster() app, includes all routers
+├── config.py # Settings / env vars
+├── dependencies.py # Shared Depends() callables
+├── models/
+│ ├── __init__.py
+│ ├── item.py # Item, ItemCreate, ItemUpdate structs
+│ └── user.py # User, UserCreate structs
+├── routers/
+│ ├── __init__.py
+│ ├── items.py # FasterRouter for /items
+│ ├── users.py # FasterRouter for /users
+│ └── auth.py # FasterRouter for /auth
+├── services/
+│ ├── __init__.py
+│ ├── item_service.py # Business logic (no HTTP)
+│ └── user_service.py
+└── tests/
+ ├── conftest.py
+ ├── test_items.py
+ └── test_users.py
+```
+
+## models/item.py
+
+```python
+import msgspec
+
+
+class ItemCreate(msgspec.Struct):
+ name: str
+ price: float
+ description: str = ""
+
+
+class Item(msgspec.Struct):
+ id: int
+ name: str
+ price: float
+ description: str
+```
+
+## dependencies.py
+
+```python
+from FasterAPI import Header, HTTPException, Depends
+
+
+async def get_current_user(authorization: str | None = Header(default=None)):
+ if not authorization or not authorization.startswith("Bearer "):
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ return {"user_id": 1} # validate token in production
+
+
+async def require_admin(user: dict = Depends(get_current_user)):
+ if user.get("role") != "admin":
+ raise HTTPException(status_code=403, detail="Admin required")
+```
+
+## routers/items.py
+
+```python
+from FasterAPI import FasterRouter, HTTPException, Depends, Path
+from models.item import Item, ItemCreate
+from dependencies import get_current_user
+
+router = FasterRouter()
+
+_db: dict[int, Item] = {}
+_next_id = 1
+
+
+@router.get("/", tags=["items"])
+async def list_items():
+ return list(_db.values())
+
+
+@router.post("/", status_code=201, tags=["items"])
+async def create_item(
+ body: ItemCreate,
+ user: dict = Depends(get_current_user),
+):
+ global _next_id
+ item = Item(id=_next_id, name=body.name, price=body.price, description=body.description)
+ _db[_next_id] = item
+ _next_id += 1
+ return item
+
+
+@router.get("/{item_id}", tags=["items"])
+async def get_item(item_id: int = Path()):
+ if item_id not in _db:
+ raise HTTPException(status_code=404, detail="Item not found")
+ return _db[item_id]
+
+
+@router.delete("/{item_id}", status_code=204, tags=["items"])
+async def delete_item(
+ item_id: int = Path(),
+ user: dict = Depends(get_current_user),
+):
+ if item_id not in _db:
+ raise HTTPException(status_code=404, detail="Item not found")
+ del _db[item_id]
+```
+
+## main.py
+
+```python
+from FasterAPI import Faster
+from routers.items import router as items_router
+from routers.users import router as users_router
+from routers.auth import router as auth_router
+
+app = Faster(
+ title="My API",
+ description="Production-ready multi-router API",
+ version="1.0.0",
+)
+
+app.include_router(auth_router, prefix="/auth")
+app.include_router(items_router, prefix="/items")
+app.include_router(users_router, prefix="/users")
+```
+
+## Shared dependencies per router
+
+Apply a dependency to **all routes in a router** by passing it to `include_router`:
+
+```python
+from dependencies import require_admin
+
+# All /admin routes require admin access
+app.include_router(admin_router, prefix="/admin", tags=["admin"])
+
+# individual routes still declare Depends(require_admin) explicitly
+# (router-level dependency injection is not yet a built-in feature)
+```
+
+## Configuration
+
+```python
+# config.py
+import os
+import msgspec
+
+
+class Config(msgspec.Struct):
+ db_url: str
+ secret_key: str
+ debug: bool = False
+
+
+_config: Config | None = None
+
+
+def get_config() -> Config:
+ global _config
+ if _config is None:
+ _config = Config(
+ db_url=os.environ["DATABASE_URL"],
+ secret_key=os.environ["SECRET_KEY"],
+ debug=os.environ.get("DEBUG", "false").lower() == "true",
+ )
+ return _config
+```
+
+## Testing big apps
+
+```python
+# tests/conftest.py
+import pytest
+from FasterAPI import TestClient
+from main import app
+
+
+@pytest.fixture(scope="session")
+def client():
+ with TestClient(app) as c:
+ yield c
+```
+
+## Next steps
+
+- [Sub-applications](sub-applications.md) — `FasterRouter` details.
+- [Settings & Environment Variables](settings.md) — configuration management.
+- [Testing with Overrides](testing-overrides.md) — swap deps per test.
diff --git a/docs/advanced/custom-response.md b/docs/advanced/custom-response.md
new file mode 100644
index 0000000..607c4a6
--- /dev/null
+++ b/docs/advanced/custom-response.md
@@ -0,0 +1,158 @@
+# Custom Response Classes
+
+FasterAPI ships several response classes. Return one directly from a route handler
+for full control over status code, headers, content type, and body encoding.
+
+## Response hierarchy
+
+```
+Response
+├── JSONResponse (msgspec.json.encode, application/json)
+├── HTMLResponse (text/html)
+├── PlainTextResponse (text/plain)
+└── RedirectResponse (Location header, 307 by default)
+StreamingResponse (async/sync iterator)
+FileResponse (disk file, with Content-Disposition)
+```
+
+## JSONResponse
+
+The default when you return a dict, struct, or primitive — but you can construct it
+explicitly for custom status or headers:
+
+```python
+from FasterAPI import Faster, JSONResponse
+
+app = Faster()
+
+
+@app.get("/items")
+async def get_items():
+ return JSONResponse(
+ content={"items": []},
+ status_code=200,
+ headers={"X-Total-Count": "0"},
+ )
+```
+
+## HTMLResponse
+
+```python
+from FasterAPI import HTMLResponse
+
+
+@app.get("/", response_class=HTMLResponse)
+async def homepage():
+ return HTMLResponse("""
+
+
FasterAPI
+ """)
+```
+
+## PlainTextResponse
+
+```python
+from FasterAPI import PlainTextResponse
+
+
+@app.get("/health")
+async def health():
+ return PlainTextResponse("OK")
+```
+
+## RedirectResponse
+
+```python
+from FasterAPI import RedirectResponse
+
+
+@app.get("/old-path")
+async def old_path():
+ return RedirectResponse(url="/new-path", status_code=301)
+```
+
+## StreamingResponse
+
+Use an async generator to stream large responses without buffering the full body in
+memory:
+
+```python
+import asyncio
+from FasterAPI import StreamingResponse
+
+
+async def large_csv():
+ yield b"id,name\n"
+ for i in range(1_000_000):
+ yield f"{i},item-{i}\n".encode()
+ if i % 1000 == 0:
+ await asyncio.sleep(0) # yield to event loop
+
+
+@app.get("/export.csv")
+async def export():
+ return StreamingResponse(large_csv(), media_type="text/csv")
+```
+
+Sync iterators also work:
+
+```python
+def sync_stream():
+ for chunk in read_chunks():
+ yield chunk
+
+StreamingResponse(sync_stream(), media_type="application/octet-stream")
+```
+
+## FileResponse
+
+Serve a file from disk with automatic MIME-type detection:
+
+```python
+from FasterAPI import FileResponse
+
+
+@app.get("/report")
+async def download_report():
+ return FileResponse(
+ path="data/report.xlsx",
+ filename="monthly-report.xlsx", # Content-Disposition filename
+ )
+```
+
+## Custom `Response` subclass
+
+Build a response class for a non-standard content type:
+
+```python
+import msgpack
+from FasterAPI.response import Response
+
+
+class MsgPackResponse(Response):
+ media_type = "application/msgpack"
+
+ def _render(self, content):
+ return msgpack.packb(content, use_bin_type=True)
+
+
+@app.get("/data", response_class=MsgPackResponse)
+async def packed_data():
+ return MsgPackResponse({"key": "value"})
+```
+
+## Setting response headers in route handlers
+
+```python
+@app.get("/items")
+async def items_with_etag():
+ return JSONResponse(
+ {"items": []},
+ headers={"ETag": '"abc123"', "Cache-Control": "max-age=60"},
+ )
+```
+
+## Next steps
+
+- [Response Cookies & Headers](response-cookies-headers.md)
+- [Server-Sent Events](server-sent-events.md) — streaming events to the browser.
diff --git a/docs/advanced/index.md b/docs/advanced/index.md
new file mode 100644
index 0000000..47fb190
--- /dev/null
+++ b/docs/advanced/index.md
@@ -0,0 +1,26 @@
+# Advanced User Guide
+
+This section covers capabilities beyond the basics — production patterns, advanced
+OpenAPI customisation, real-time transports, and testing strategies.
+
+## Pages
+
+| Topic | What you learn |
+|---|---|
+| [Custom Response Classes](custom-response.md) | `Response`, `JSONResponse`, `StreamingResponse`, `FileResponse` |
+| [Response Cookies & Headers](response-cookies-headers.md) | Set cookies and custom headers on responses |
+| [Using the Request Directly](using-request.md) | Access raw request data, headers, client IP |
+| [Settings & Environment Variables](settings.md) | Twelve-factor config with `os.environ` / `python-dotenv` |
+| [OpenAPI Customisation](openapi-customization.md) | Conditional docs, extending the schema |
+| [Templates (Jinja2)](templates.md) | Server-side HTML rendering |
+| [Lifespan Events](lifespan.md) | Startup/shutdown hooks for connections and caches |
+| [Behind a Proxy](behind-proxy.md) | Root path, forwarded headers, Nginx/Traefik |
+| [Sub-applications](sub-applications.md) | Mount multiple ASGI apps |
+| [Server-Sent Events](server-sent-events.md) | Push real-time updates to browsers |
+| [Testing with Overrides](testing-overrides.md) | Swap dependencies in tests |
+| [Async Tests](async-tests.md) | `pytest-asyncio`, async fixtures |
+| [Bigger Applications](bigger-apps.md) | Routers, multiple files, packages |
+
+## Prerequisites
+
+Complete the [Tutorial](../tutorial/index.md) before reading this section.
diff --git a/docs/advanced/lifespan.md b/docs/advanced/lifespan.md
new file mode 100644
index 0000000..efa9d78
--- /dev/null
+++ b/docs/advanced/lifespan.md
@@ -0,0 +1,133 @@
+# Lifespan Events
+
+Lifespan events let you run code **once at startup** and **once at shutdown** — ideal
+for opening database connections, warming caches, or releasing resources cleanly.
+
+## `on_startup` and `on_shutdown`
+
+```python
+from FasterAPI import Faster
+
+app = Faster()
+
+_db_pool = None
+
+
+@app.on_startup
+async def startup():
+ global _db_pool
+ # e.g. open an async DB connection pool
+ _db_pool = await open_pool()
+ print("Database connected")
+
+
+@app.on_shutdown
+async def shutdown():
+ if _db_pool:
+ await _db_pool.close()
+ print("Database disconnected")
+```
+
+## Synchronous handlers
+
+Both async and sync callables are supported:
+
+```python
+@app.on_startup
+def load_ml_model():
+ global model
+ import joblib
+ model = joblib.load("model.pkl")
+ print("Model loaded")
+```
+
+## Multiple handlers
+
+Register as many as you need — they run in registration order:
+
+```python
+@app.on_startup
+async def connect_db(): ...
+
+@app.on_startup
+async def connect_redis(): ...
+
+@app.on_startup
+def warm_cache(): ...
+```
+
+## Sharing state between handlers and routes
+
+Use module-level variables or a dedicated state object:
+
+```python
+class AppState:
+ db: object = None
+ cache: dict = {}
+
+state = AppState()
+
+
+@app.on_startup
+async def init_db():
+ state.db = await create_connection()
+
+
+@app.get("/items")
+async def list_items():
+ rows = await state.db.fetch("SELECT * FROM items")
+ return rows
+```
+
+## Startup validation
+
+Fail fast if required configuration is missing:
+
+```python
+import os, sys
+
+@app.on_startup
+def validate_config():
+ required = ["DATABASE_URL", "SECRET_KEY"]
+ missing = [k for k in required if not os.environ.get(k)]
+ if missing:
+ print(f"Missing env vars: {missing}", file=sys.stderr)
+ raise RuntimeError(f"Missing required configuration: {missing}")
+```
+
+If a startup handler raises, the ASGI server receives `lifespan.startup.failed` and
+terminates the process — preventing a misconfigured app from accepting requests.
+
+## Using lifespan with ASGI servers
+
+FasterAPI handles the `lifespan` ASGI scope natively. Pass `--lifespan on` to
+uvicorn (the default):
+
+```bash
+uvicorn main:app --lifespan on
+```
+
+## Contextlib pattern (future-compatible)
+
+For testing or frameworks that prefer a context manager, you can wrap the handlers:
+
+```python
+from contextlib import asynccontextmanager
+
+
+@asynccontextmanager
+async def lifespan():
+ # startup
+ state.db = await create_connection()
+ yield
+ # shutdown
+ await state.db.close()
+
+
+# FasterAPI 's on_startup / on_shutdown approach covers the same ground
+```
+
+## Next steps
+
+- [Settings & Environment Variables](settings.md) — validate config at startup.
+- [Database Integration](../database/index.md) — connection pools and sessions.
diff --git a/docs/advanced/openapi-customization.md b/docs/advanced/openapi-customization.md
new file mode 100644
index 0000000..45a604d
--- /dev/null
+++ b/docs/advanced/openapi-customization.md
@@ -0,0 +1,156 @@
+# OpenAPI Customisation
+
+FasterAPI generates an OpenAPI 3.x schema automatically. This page covers how to
+conditionally show it, extend it, and override generated metadata.
+
+## Disable docs in production
+
+```python
+import os
+from FasterAPI import Faster
+
+is_prod = os.environ.get("ENV") == "production"
+
+app = Faster(
+ docs_url=None if is_prod else "/docs",
+ redoc_url=None if is_prod else "/redoc",
+ openapi_url=None if is_prod else "/openapi.json",
+)
+```
+
+## Change the docs URLs
+
+```python
+app = Faster(
+ docs_url="/api/docs",
+ redoc_url="/api/redoc",
+ openapi_url="/api/openapi.json",
+)
+```
+
+## Add tags metadata
+
+Provide descriptions and external documentation links for each tag:
+
+```python
+from FasterAPI import Faster
+
+tags_metadata = [
+ {
+ "name": "items",
+ "description": "Operations on inventory items.",
+ },
+ {
+ "name": "users",
+ "description": "User management. See the [docs](https://example.com).",
+ "externalDocs": {
+ "description": "External docs",
+ "url": "https://example.com/users",
+ },
+ },
+]
+
+app = Faster(
+ title="Inventory API",
+ description="Full inventory management system.",
+ version="1.0.0",
+)
+```
+
+## Route-level OpenAPI fields
+
+```python
+@app.get(
+ "/items/{item_id}",
+ summary="Get item by ID",
+ tags=["items"],
+ deprecated=False,
+ status_code=200,
+)
+async def get_item(item_id: int):
+ """Retrieve a single inventory item.
+
+ Returns 404 if the item does not exist.
+ """
+ ...
+```
+
+The **docstring** becomes the route description in Swagger UI.
+
+## Extending the generated schema
+
+The schema is generated by `FasterAPI.openapi.generator.generate_openapi`. To add
+custom fields (e.g. `servers`, `x-internal`), intercept the `/openapi.json` endpoint:
+
+```python
+import copy
+from FasterAPI import Faster, JSONResponse
+from FasterAPI.openapi.generator import generate_openapi
+
+app = Faster(openapi_url=None) # disable default endpoint
+
+
+def _custom_openapi():
+ schema = generate_openapi(app, title=app.title, version=app.version)
+ schema = copy.deepcopy(schema)
+ schema["servers"] = [
+ {"url": "https://api.example.com", "description": "Production"},
+ {"url": "http://localhost:8000", "description": "Development"},
+ ]
+ schema["info"]["x-logo"] = {"url": "https://example.com/logo.png"}
+ return schema
+
+
+@app.get("/openapi.json", include_in_schema=False)
+async def openapi_schema():
+ return JSONResponse(_custom_openapi())
+```
+
+## Adding security schemes
+
+```python
+_openapi_cache = None
+
+
+def get_openapi_schema():
+ global _openapi_cache
+ if _openapi_cache is None:
+ schema = generate_openapi(app, title=app.title, version=app.version)
+ schema.setdefault("components", {}).setdefault("securitySchemes", {}).update({
+ "BearerAuth": {
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "JWT",
+ }
+ })
+ schema["security"] = [{"BearerAuth": []}]
+ _openapi_cache = schema
+ return _openapi_cache
+```
+
+## Hiding internal routes
+
+Set `include_in_schema=False` (currently achieved by using the `openapi` tag and
+filtering, or simply by not declaring the route through the public decorator):
+
+```python
+# Internal health probe — not shown in API docs
+async def _internal_health():
+ return {"ok": True}
+
+app._add_route(
+ "GET",
+ "/_health",
+ _internal_health,
+ tags=["internal"],
+ summary="",
+ response_model=None,
+ status_code=200,
+ deprecated=False,
+)
+```
+
+## Next steps
+
+- [Metadata & Docs](../tutorial/metadata.md) — per-route tags and descriptions.
+- [Bigger Applications](bigger-apps.md) — organise routes and schemas at scale.
diff --git a/docs/advanced/response-cookies-headers.md b/docs/advanced/response-cookies-headers.md
new file mode 100644
index 0000000..a4beba8
--- /dev/null
+++ b/docs/advanced/response-cookies-headers.md
@@ -0,0 +1,148 @@
+# Response Cookies & Headers
+
+## Setting response headers
+
+Pass a `headers` dict to any response class:
+
+```python
+from FasterAPI import Faster, JSONResponse
+
+app = Faster()
+
+
+@app.get("/items")
+async def list_items():
+ return JSONResponse(
+ {"items": []},
+ headers={
+ "X-Total-Count": "0",
+ "Cache-Control": "public, max-age=60",
+ "X-Request-ID": "abc-123",
+ },
+ )
+```
+
+### Security headers
+
+```python
+SECURITY_HEADERS = {
+ "Strict-Transport-Security": "max-age=63072000; includeSubDomains",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ "Content-Security-Policy": "default-src 'self'",
+ "Referrer-Policy": "strict-origin-when-cross-origin",
+}
+
+
+@app.get("/secure")
+async def secure_endpoint():
+ return JSONResponse({"ok": True}, headers=SECURITY_HEADERS)
+```
+
+### Headers via middleware
+
+Apply headers to every response without touching individual routes:
+
+```python
+from FasterAPI import BaseHTTPMiddleware
+
+
+class SecurityHeadersMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, scope, receive, send):
+ async def add_headers(message):
+ if message["type"] == "http.response.start":
+ headers = list(message.get("headers", []))
+ for k, v in SECURITY_HEADERS.items():
+ headers.append((k.lower().encode(), v.encode()))
+ message = {**message, "headers": headers}
+ await send(message)
+
+ await self.app(scope, receive, add_headers)
+
+
+app.add_middleware(SecurityHeadersMiddleware)
+```
+
+## Setting cookies
+
+Use a `Response` subclass and call helpers on the underlying response object, or
+construct the `Set-Cookie` header manually:
+
+```python
+from FasterAPI import Response
+
+
+@app.post("/login")
+async def login(response: Response):
+ # FasterAPI injects Response when declared as parameter type
+ # Use headers dict for cookie setting
+ return JSONResponse(
+ {"logged_in": True},
+ headers={
+ "Set-Cookie": (
+ "session=abc123; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600"
+ )
+ },
+ )
+```
+
+### Structured cookie header builder
+
+```python
+def make_cookie(
+ name: str,
+ value: str,
+ *,
+ max_age: int | None = None,
+ path: str = "/",
+ secure: bool = True,
+ http_only: bool = True,
+ same_site: str = "Strict",
+) -> str:
+ parts = [f"{name}={value}", f"Path={path}"]
+ if max_age is not None:
+ parts.append(f"Max-Age={max_age}")
+ if secure:
+ parts.append("Secure")
+ if http_only:
+ parts.append("HttpOnly")
+ parts.append(f"SameSite={same_site}")
+ return "; ".join(parts)
+
+
+@app.post("/auth")
+async def auth():
+ cookie = make_cookie("token", "jwt-here", max_age=3600)
+ return JSONResponse({"ok": True}, headers={"Set-Cookie": cookie})
+```
+
+### Deleting a cookie
+
+Set `Max-Age=0`:
+
+```python
+@app.post("/logout")
+async def logout():
+ return JSONResponse(
+ {"logged_out": True},
+ headers={"Set-Cookie": "session=; Max-Age=0; Path=/; HttpOnly; Secure"},
+ )
+```
+
+## Reading cookies in a request
+
+```python
+from FasterAPI import Cookie
+
+
+@app.get("/profile")
+async def profile(session: str | None = Cookie(default=None)):
+ if session is None:
+ return {"authenticated": False}
+ return {"session": session}
+```
+
+## Next steps
+
+- [Custom Response Classes](custom-response.md) — full response control.
+- [Security: HTTP Basic Auth](../security/http-basic-auth.md) — cookie-based auth patterns.
diff --git a/docs/advanced/server-sent-events.md b/docs/advanced/server-sent-events.md
new file mode 100644
index 0000000..8343206
--- /dev/null
+++ b/docs/advanced/server-sent-events.md
@@ -0,0 +1,177 @@
+# Server-Sent Events (SSE)
+
+Server-Sent Events (SSE) is a W3C standard for **one-way push** from server to
+browser over a persistent HTTP connection — simpler than WebSockets when you only
+need server-to-client updates.
+
+## How SSE works
+
+The client opens a regular `GET` request. The server responds with
+`Content-Type: text/event-stream` and streams newline-delimited event payloads
+indefinitely. The browser's `EventSource` API reconnects automatically on
+disconnect.
+
+## Basic SSE endpoint
+
+```python
+import asyncio
+from FasterAPI import Faster, StreamingResponse
+
+app = Faster()
+
+
+async def event_generator():
+ count = 0
+ while True:
+ count += 1
+ yield f"data: count={count}\n\n".encode()
+ await asyncio.sleep(1)
+
+
+@app.get("/events")
+async def sse_endpoint():
+ return StreamingResponse(
+ event_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no", # disable Nginx buffering
+ },
+ )
+```
+
+## Event format
+
+Each event is a block of `field: value` lines followed by a blank line:
+
+```
+data: hello world\n
+\n
+
+event: update\n
+data: {"key":"value"}\n
+id: 42\n
+\n
+```
+
+Fields:
+
+| Field | Purpose |
+|---|---|
+| `data` | Event payload (required) |
+| `event` | Custom event type (default: `message`) |
+| `id` | Last-event-ID for reconnect |
+| `retry` | Reconnect delay in ms |
+
+## Typed JSON events
+
+```python
+import json
+import msgspec
+
+
+async def stock_events():
+ while True:
+ price = {"symbol": "FAST", "price": 123.45}
+ yield f"event: price\ndata: {json.dumps(price)}\n\n".encode()
+ await asyncio.sleep(0.5)
+
+
+@app.get("/stocks")
+async def stock_stream():
+ return StreamingResponse(stock_events(), media_type="text/event-stream")
+```
+
+## Client-side usage
+
+```javascript
+const source = new EventSource("/events");
+
+source.onmessage = (e) => {
+ console.log("Message:", e.data);
+};
+
+source.addEventListener("price", (e) => {
+ const data = JSON.parse(e.data);
+ console.log("Price update:", data);
+});
+
+source.onerror = () => {
+ console.log("Connection lost, reconnecting...");
+};
+```
+
+## Disconnect detection
+
+The connection closes when the client disconnects, but the generator keeps yielding
+until the next `await`. Check `request.is_disconnected()` or wrap in a try/except:
+
+```python
+from FasterAPI import Request
+
+
+async def live_feed(request: Request):
+ while True:
+ if await request.is_disconnected():
+ break
+ yield b"data: tick\n\n"
+ await asyncio.sleep(1)
+
+
+@app.get("/feed")
+async def feed(request: Request):
+ return StreamingResponse(live_feed(request), media_type="text/event-stream")
+```
+
+## Multiple clients with a shared queue
+
+```python
+import asyncio
+from collections import defaultdict
+
+subscribers: dict[int, asyncio.Queue] = {}
+_next_id = 0
+
+
+def publish(message: str):
+ for q in subscribers.values():
+ q.put_nowait(message)
+
+
+async def subscribe(request: Request):
+ global _next_id
+ _next_id += 1
+ sid = _next_id
+ subscribers[sid] = asyncio.Queue()
+ try:
+ while True:
+ if await request.is_disconnected():
+ break
+ try:
+ msg = await asyncio.wait_for(subscribers[sid].get(), timeout=15)
+ yield f"data: {msg}\n\n".encode()
+ except asyncio.TimeoutError:
+ yield b": keepalive\n\n" # comment to prevent proxy timeout
+ finally:
+ del subscribers[sid]
+
+
+@app.get("/notifications")
+async def notifications(request: Request):
+ return StreamingResponse(subscribe(request), media_type="text/event-stream")
+```
+
+## SSE vs WebSockets
+
+| | SSE | WebSocket |
+|---|---|---|
+| Direction | Server → Client | Bidirectional |
+| Protocol | HTTP | ws:// / wss:// |
+| Auto-reconnect | Yes (browser) | No (manual) |
+| Proxy support | Universal | Variable |
+| Use case | Live feeds, notifications | Chat, gaming, collaboration |
+
+## Next steps
+
+- [WebSockets](../tutorial/websockets.md) — bidirectional real-time communication.
+- [Custom Response Classes](custom-response.md) — StreamingResponse details.
diff --git a/docs/advanced/settings.md b/docs/advanced/settings.md
new file mode 100644
index 0000000..f8a226f
--- /dev/null
+++ b/docs/advanced/settings.md
@@ -0,0 +1,156 @@
+# Settings & Environment Variables
+
+Twelve-factor applications read configuration from the environment. This page shows
+patterns for managing settings in a FasterAPI project.
+
+## Reading from `os.environ`
+
+The simplest approach — read environment variables directly:
+
+```python
+import os
+from FasterAPI import Faster
+
+DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./dev.db")
+SECRET_KEY = os.environ["SECRET_KEY"] # raises KeyError if missing
+DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
+
+app = Faster(title="My App")
+```
+
+## Using `python-dotenv` for local development
+
+Install:
+
+```bash
+pip install python-dotenv
+```
+
+Create a `.env` file (never commit it):
+
+```env
+DATABASE_URL=postgresql://user:pass@localhost/mydb
+SECRET_KEY=dev-secret-do-not-use-in-prod
+DEBUG=true
+```
+
+Load it before reading variables:
+
+```python
+from dotenv import load_dotenv
+load_dotenv() # must run before os.environ reads
+
+import os
+DATABASE_URL = os.environ["DATABASE_URL"]
+```
+
+## Settings class pattern
+
+Group all settings in a dataclass for easy access and type safety:
+
+```python
+import os
+import msgspec
+
+
+class Settings(msgspec.Struct):
+ database_url: str
+ secret_key: str
+ debug: bool = False
+ allowed_hosts: list[str] = []
+ workers: int = 1
+
+
+def load_settings() -> Settings:
+ return Settings(
+ database_url=os.environ["DATABASE_URL"],
+ secret_key=os.environ["SECRET_KEY"],
+ debug=os.environ.get("DEBUG", "false").lower() == "true",
+ allowed_hosts=os.environ.get("ALLOWED_HOSTS", "").split(","),
+ workers=int(os.environ.get("WORKERS", "1")),
+ )
+
+
+settings = load_settings()
+```
+
+## Injecting settings via `Depends`
+
+```python
+from FasterAPI import Depends
+from functools import lru_cache
+
+
+@lru_cache(maxsize=1)
+def get_settings() -> Settings:
+ return load_settings()
+
+
+@app.get("/config-info")
+async def config_info(cfg: Settings = Depends(get_settings)):
+ return {"debug": cfg.debug, "workers": cfg.workers}
+```
+
+Using `lru_cache` means settings are loaded once and reused on every request.
+
+## Environment-specific files
+
+```
+.env # shared defaults (safe to commit if no secrets)
+.env.local # local overrides (git-ignored)
+.env.production # production values (never in repo)
+```
+
+```python
+from dotenv import load_dotenv
+
+load_dotenv(".env")
+load_dotenv(".env.local", override=True)
+```
+
+## Validating configuration at startup
+
+Fail fast if required variables are missing:
+
+```python
+import sys
+
+REQUIRED = ["DATABASE_URL", "SECRET_KEY"]
+
+missing = [k for k in REQUIRED if not os.environ.get(k)]
+if missing:
+ print(f"Missing required environment variables: {missing}", file=sys.stderr)
+ sys.exit(1)
+```
+
+Or raise in a lifespan handler — see [Lifespan Events](lifespan.md).
+
+## Using settings in `Faster()` constructor
+
+```python
+app = Faster(
+ title="My API",
+ version=settings.app_version,
+ # Hide docs in production
+ docs_url="/docs" if settings.debug else None,
+ redoc_url="/redoc" if settings.debug else None,
+)
+```
+
+## Docker / container environments
+
+Pass settings as environment variables in `docker-compose.yml`:
+
+```yaml
+services:
+ api:
+ image: my-app
+ environment:
+ DATABASE_URL: postgresql://postgres:pass@db/mydb
+ SECRET_KEY: ${SECRET_KEY} # from host environment
+```
+
+## Next steps
+
+- [Lifespan Events](lifespan.md) — initialise connections using settings at startup.
+- [Deployment: Docker](../deployment/docker.md) — full container deployment guide.
diff --git a/docs/advanced/sub-applications.md b/docs/advanced/sub-applications.md
new file mode 100644
index 0000000..0517758
--- /dev/null
+++ b/docs/advanced/sub-applications.md
@@ -0,0 +1,140 @@
+# Sub-applications
+
+FasterAPI's `include_router` lets you split your API across multiple files and
+routers, each mounted under a shared prefix.
+
+## Basic router
+
+```python
+# items/router.py
+from FasterAPI import FasterRouter, HTTPException
+import msgspec
+
+router = FasterRouter()
+
+_items: dict[int, dict] = {}
+
+
+class Item(msgspec.Struct):
+ name: str
+ price: float
+
+
+@router.get("/", tags=["items"])
+async def list_items():
+ return list(_items.values())
+
+
+@router.post("/", status_code=201, tags=["items"])
+async def create_item(item: Item):
+ new_id = len(_items) + 1
+ _items[new_id] = {"id": new_id, **msgspec.structs.asdict(item)}
+ return _items[new_id]
+
+
+@router.get("/{item_id}", tags=["items"])
+async def get_item(item_id: int):
+ if item_id not in _items:
+ raise HTTPException(status_code=404, detail="Not found")
+ return _items[item_id]
+```
+
+```python
+# main.py
+from FasterAPI import Faster
+from items.router import router as items_router
+from users.router import router as users_router
+
+app = Faster(title="Multi-router API")
+
+app.include_router(items_router, prefix="/items")
+app.include_router(users_router, prefix="/users", tags=["users"])
+```
+
+## Router with prefix and tags
+
+All routes registered through the router inherit the `prefix` and `tags` passed to
+`include_router`:
+
+```python
+# The router's @router.get("/") becomes GET /items/
+# @router.get("/{item_id}") becomes GET /items/{item_id}
+app.include_router(items_router, prefix="/items", tags=["items"])
+```
+
+## Multiple versions
+
+```python
+from v1.router import router as v1_router
+from v2.router import router as v2_router
+
+app.include_router(v1_router, prefix="/v1")
+app.include_router(v2_router, prefix="/v2")
+```
+
+## Nested routers
+
+```python
+# admin/users.py
+admin_users_router = FasterRouter()
+
+# admin/__init__.py
+from FasterAPI import FasterRouter
+from .users import admin_users_router
+
+admin_router = FasterRouter()
+# Include nested router — FasterRouter.include_router mirrors app.include_router
+```
+
+Currently `FasterRouter` does not expose `include_router` directly; nest via
+`app.include_router` with different prefixes:
+
+```python
+app.include_router(admin_users_router, prefix="/admin/users", tags=["admin"])
+app.include_router(admin_settings_router, prefix="/admin/settings", tags=["admin"])
+```
+
+## Recommended project layout
+
+```
+myproject/
+├── main.py # create Faster() app, include_router calls
+├── dependencies.py # shared Depends() callables
+├── models.py # shared msgspec.Struct definitions
+├── items/
+│ ├── __init__.py
+│ ├── router.py # FasterRouter with /items routes
+│ └── models.py
+├── users/
+│ ├── __init__.py
+│ ├── router.py
+│ └── models.py
+└── auth/
+ ├── __init__.py
+ └── router.py
+```
+
+See [Bigger Applications](bigger-apps.md) for a fully-worked example.
+
+## ASGI sub-applications
+
+Mount a completely separate ASGI app (e.g. another FasterAPI instance or a
+third-party ASGI app) by routing a prefix manually:
+
+```python
+admin_app = Faster(title="Admin API", docs_url="/docs")
+
+# Dispatch /admin/* to admin_app
+@app.get("/admin/{rest_of_path:path}")
+async def admin_proxy(rest_of_path: str, request: Request):
+ # adjust scope and dispatch
+ ...
+```
+
+For full ASGI mounting, wrap at the ASGI level outside FasterAPI using a helper like
+`a2wsgi` or write a custom router.
+
+## Next steps
+
+- [Bigger Applications](bigger-apps.md) — end-to-end multi-file project.
+- [Metadata & Docs](../tutorial/metadata.md) — tags and route descriptions at scale.
diff --git a/docs/advanced/templates.md b/docs/advanced/templates.md
new file mode 100644
index 0000000..f6e0ace
--- /dev/null
+++ b/docs/advanced/templates.md
@@ -0,0 +1,168 @@
+# Templates (Jinja2)
+
+FasterAPI is ASGI-native and has no built-in template engine, but integrating
+**Jinja2** for server-side HTML rendering is straightforward.
+
+## Installation
+
+```bash
+pip install jinja2
+```
+
+## Basic setup
+
+```python
+from pathlib import Path
+from jinja2 import Environment, FileSystemLoader
+from FasterAPI import Faster, Request
+from FasterAPI.response import HTMLResponse
+
+app = Faster()
+
+templates = Environment(
+ loader=FileSystemLoader(Path(__file__).parent / "templates"),
+ autoescape=True,
+)
+
+
+def render(name: str, **context) -> HTMLResponse:
+ tmpl = templates.get_template(name)
+ return HTMLResponse(tmpl.render(**context))
+```
+
+## Template directory layout
+
+```
+myapp/
+├── main.py
+└── templates/
+ ├── base.html
+ ├── index.html
+ └── items/
+ ├── list.html
+ └── detail.html
+```
+
+## Example templates
+
+`templates/base.html`:
+
+```html
+
+
+
+
+ {% block title %}FasterAPI App{% endblock %}
+
+
+
+ {% block content %}{% endblock %}
+
+
+```
+
+`templates/items/list.html`:
+
+```html
+{% extends "base.html" %}
+{% block title %}Items{% endblock %}
+{% block content %}
+Items
+
+{% endblock %}
+```
+
+## Route handlers
+
+```python
+@app.get("/")
+async def homepage(request: Request):
+ return render("index.html", request=request)
+
+
+@app.get("/items")
+async def item_list(request: Request):
+ items = [{"id": 1, "name": "Widget"}, {"id": 2, "name": "Gadget"}]
+ return render("items/list.html", request=request, items=items)
+
+
+@app.get("/items/{item_id}")
+async def item_detail(item_id: int, request: Request):
+ item = {"id": item_id, "name": f"Item {item_id}"}
+ return render("items/detail.html", request=request, item=item)
+```
+
+## Serving static files
+
+Mount a static-file handler using any ASGI-compatible static server. A simple
+option using `whitenoise`:
+
+```bash
+pip install whitenoise
+```
+
+```python
+from whitenoise import WhiteNoise
+import os
+
+# Wrap app with WhiteNoise AFTER creating Faster instance
+# WhiteNoise requires wrapping at the ASGI level
+static_app = WhiteNoise(app, root="static/", prefix="static")
+```
+
+Run with: `uvicorn main:static_app`
+
+Or serve files directly from a route:
+
+```python
+from FasterAPI import FileResponse
+
+
+@app.get("/static/{filename}")
+async def static_file(filename: str):
+ path = Path("static") / filename
+ if not path.exists():
+ from FasterAPI import HTTPException
+ raise HTTPException(404)
+ return FileResponse(path)
+```
+
+## Caching templates in production
+
+```python
+from jinja2 import Environment, FileSystemLoader, BytecodeCache
+import os
+
+env = Environment(
+ loader=FileSystemLoader("templates"),
+ autoescape=True,
+ # Enable bytecode cache in production for faster rendering
+ bytecode_cache=BytecodeCache() if os.environ.get("ENV") == "production" else None,
+)
+```
+
+## Context processors
+
+Share common data (e.g. current user) across all templates:
+
+```python
+def base_context(request: Request) -> dict:
+ return {
+ "request": request,
+ "app_name": "My FasterAPI App",
+ }
+
+
+def render(name: str, request: Request, **extra) -> HTMLResponse:
+ ctx = {**base_context(request), **extra}
+ return HTMLResponse(templates.get_template(name).render(**ctx))
+```
+
+## Next steps
+
+- [Custom Response Classes](custom-response.md) — return HTML, streams, files.
+- [Lifespan Events](lifespan.md) — initialise the template engine once at startup.
diff --git a/docs/advanced/testing-overrides.md b/docs/advanced/testing-overrides.md
new file mode 100644
index 0000000..0956322
--- /dev/null
+++ b/docs/advanced/testing-overrides.md
@@ -0,0 +1,161 @@
+# Testing with Dependency Overrides
+
+Replace real dependencies (databases, external services, auth) with test doubles
+during testing, without changing production code.
+
+## Setup
+
+Install the testing extras:
+
+```bash
+pip install faster-api-web[test] # includes httpx
+pip install pytest pytest-asyncio
+```
+
+## Basic test with `TestClient`
+
+```python
+# main.py
+from FasterAPI import Faster, Depends
+
+app = Faster()
+
+
+async def get_db():
+ return {"host": "postgres://prod"}
+
+
+@app.get("/items")
+async def list_items(db: dict = Depends(get_db)):
+ return {"db_host": db["host"]}
+```
+
+```python
+# tests/test_main.py
+from FasterAPI import TestClient
+from main import app, get_db
+
+client = TestClient(app)
+
+
+def test_list_items_with_real_dep():
+ response = client.get("/items")
+ assert response.status_code == 200
+```
+
+## Overriding dependencies
+
+```python
+# tests/test_main.py
+from FasterAPI import TestClient
+from main import app, get_db
+
+client = TestClient(app)
+
+
+def fake_db():
+ return {"host": "sqlite:///:memory:"}
+
+
+def test_with_fake_db():
+ app.dependency_overrides[get_db] = fake_db
+ response = client.get("/items")
+ assert response.json() == {"db_host": "sqlite:///:memory:"}
+ app.dependency_overrides.clear()
+```
+
+!!! tip
+ Always clear overrides after each test to avoid state leaking between tests.
+
+## Using pytest fixtures
+
+```python
+import pytest
+from FasterAPI import TestClient
+from main import app, get_db
+
+
+@pytest.fixture
+def client():
+ app.dependency_overrides[get_db] = lambda: {"host": "sqlite:///:memory:"}
+ with TestClient(app) as c:
+ yield c
+ app.dependency_overrides.clear()
+
+
+def test_items(client):
+ r = client.get("/items")
+ assert r.status_code == 200
+```
+
+## Overriding auth dependencies
+
+```python
+# auth.py
+from FasterAPI import Header, HTTPException
+
+
+async def get_current_user(authorization: str = Header()):
+ if not authorization.startswith("Bearer "):
+ raise HTTPException(401)
+ token = authorization.removeprefix("Bearer ")
+ # validate JWT in production
+ return {"user_id": 1, "token": token}
+```
+
+```python
+# tests/conftest.py
+import pytest
+from FasterAPI import TestClient
+from main import app
+from auth import get_current_user
+
+
+@pytest.fixture
+def authenticated_client():
+ async def fake_user():
+ return {"user_id": 42, "token": "test-token"}
+
+ app.dependency_overrides[get_current_user] = fake_user
+ with TestClient(app) as c:
+ yield c
+ app.dependency_overrides.clear()
+```
+
+## Testing error paths
+
+```python
+def test_missing_item(client):
+ r = client.get("/items/9999")
+ assert r.status_code == 404
+ assert r.json()["detail"] == "Not found"
+```
+
+## Testing with request headers
+
+```python
+def test_requires_auth(client):
+ r = client.get("/me")
+ assert r.status_code == 401
+
+def test_authenticated(authenticated_client):
+ r = authenticated_client.get("/me")
+ assert r.status_code == 200
+```
+
+## TestClient and lifespan
+
+`TestClient` triggers `on_startup` / `on_shutdown` handlers when used as a context
+manager:
+
+```python
+with TestClient(app) as client:
+ # startup handlers have run
+ r = client.get("/items")
+# shutdown handlers have run
+```
+
+## Next steps
+
+- [Async Tests](async-tests.md) — test async code with `pytest-asyncio`.
+- [Dependencies](../tutorial/dependencies.md) — understand the DI system being overridden.
diff --git a/docs/advanced/using-request.md b/docs/advanced/using-request.md
new file mode 100644
index 0000000..08203a0
--- /dev/null
+++ b/docs/advanced/using-request.md
@@ -0,0 +1,123 @@
+# Using the Request Directly
+
+Declare a parameter of type `Request` in a route handler to access the raw request
+object — headers, cookies, query string, body, and client information.
+
+## Importing and using `Request`
+
+```python
+from FasterAPI import Faster, Request
+
+app = Faster()
+
+
+@app.get("/info")
+async def request_info(request: Request):
+ return {
+ "method": request.method,
+ "url": str(request.url),
+ "client": request.client,
+ "headers": dict(request.headers),
+ }
+```
+
+## Available attributes
+
+| Attribute | Type | Description |
+|---|---|---|
+| `request.method` | `str` | HTTP verb (`GET`, `POST`, …) |
+| `request.url` | `URL` | Full URL object |
+| `request.headers` | `Headers` | Request headers (case-insensitive) |
+| `request.query_params` | `QueryParams` | Parsed query string |
+| `request.cookies` | `dict[str, str]` | Parsed `Cookie` header |
+| `request.client` | `tuple[str, int] \| None` | `(host, port)` of the client |
+| `request.path_params` | `dict[str, str]` | Matched path parameters |
+
+## Reading headers
+
+```python
+@app.get("/echo-ua")
+async def echo_user_agent(request: Request):
+ ua = request.headers.get("user-agent", "unknown")
+ return {"user_agent": ua}
+```
+
+## Reading the body
+
+```python
+@app.post("/raw-body")
+async def raw_body(request: Request):
+ body = await request.body() # bytes
+ return {"size": len(body)}
+```
+
+### JSON body
+
+```python
+@app.post("/parse-json")
+async def parse_json(request: Request):
+ data = await request.json() # raises on invalid JSON
+ return {"received": data}
+```
+
+### Form data
+
+```python
+@app.post("/raw-form")
+async def raw_form(request: Request):
+ form = await request.form()
+ return {k: v for k, v in form.items() if not hasattr(v, "read")}
+```
+
+## Query parameters
+
+```python
+@app.get("/search")
+async def search(request: Request):
+ q = request.query_params.get("q", "")
+ page = int(request.query_params.get("page", 1))
+ return {"q": q, "page": page}
+```
+
+## Client IP address
+
+```python
+@app.get("/ip")
+async def client_ip(request: Request):
+ if request.client:
+ host, port = request.client
+ return {"ip": host, "port": port}
+ return {"ip": None}
+```
+
+When behind a reverse proxy, check the `X-Forwarded-For` header:
+
+```python
+@app.get("/real-ip")
+async def real_ip(request: Request):
+ forwarded_for = request.headers.get("x-forwarded-for")
+ ip = forwarded_for.split(",")[0].strip() if forwarded_for else (
+ request.client[0] if request.client else None
+ )
+ return {"ip": ip}
+```
+
+## Combining `Request` with typed parameters
+
+`Request` can coexist with any other parameter types:
+
+```python
+@app.post("/items/{item_id}")
+async def create_item(
+ item_id: int,
+ item: Item,
+ request: Request,
+):
+ ua = request.headers.get("user-agent", "")
+ return {"item_id": item_id, "name": item.name, "ua": ua}
+```
+
+## Next steps
+
+- [Settings & Environment Variables](settings.md) — configure your app from the environment.
+- [Behind a Proxy](behind-proxy.md) — handle forwarded headers correctly.
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 2a7eb34..02337c4 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -1,6 +1,448 @@
-# API reference
+# API Reference
-Generated from the installed package. For the full list of re-exports, see
+This page documents every public class and function exported from `FasterAPI`.
+For the full list of re-exports, see
[`FasterAPI/__init__.py`](https://github.com/FasterApiWeb/FasterAPI/blob/master/FasterAPI/__init__.py).
+## Application
+
+### `Faster`
+
+The main ASGI application class.
+
+```python
+from FasterAPI import Faster
+
+app = Faster(
+ title="My API", # shown in Swagger UI
+ version="1.0.0", # shown in Swagger UI
+ description="...", # Markdown description
+ openapi_url="/openapi.json", # set to None to disable
+ docs_url="/docs", # Swagger UI; None to disable
+ redoc_url="/redoc", # ReDoc; None to disable
+)
+```
+
+**Route decorators:** `@app.get`, `@app.post`, `@app.put`, `@app.delete`,
+`@app.patch`, `@app.websocket`
+
+**Lifecycle:** `@app.on_startup`, `@app.on_shutdown`
+
+**Middleware:** `app.add_middleware(MiddlewareClass, **kwargs)`
+
+**Exception handlers:** `app.add_exception_handler(ExcClass, handler)`
+
+**Router inclusion:** `app.include_router(router, prefix="", tags=())`
+
+---
+
+### `FasterRouter`
+
+Groups related routes into a reusable router.
+
+```python
+from FasterAPI import FasterRouter
+
+router = FasterRouter()
+
+@router.get("/")
+async def list_items(): ...
+
+app.include_router(router, prefix="/items", tags=["items"])
+```
+
+---
+
+## Request & Response
+
+### `Request`
+
+Represents an incoming HTTP request.
+
+```python
+from FasterAPI import Request
+
+@app.get("/info")
+async def info(request: Request):
+ return {
+ "method": request.method,
+ "path": request.url.path,
+ "client": request.client,
+ }
+```
+
+**Key attributes:**
+
+| Attribute | Type | Description |
+|---|---|---|
+| `method` | `str` | HTTP verb |
+| `url` | URL | Full URL |
+| `headers` | Headers | Request headers (case-insensitive) |
+| `query_params` | QueryParams | Parsed query string |
+| `cookies` | `dict[str, str]` | Parsed cookies |
+| `client` | `tuple[str, int] \| None` | Client IP and port |
+| `path_params` | `dict[str, str]` | Matched path segments |
+
+**Async methods:** `await request.body()`, `await request.json()`,
+`await request.form()`
+
+---
+
+### `Response`
+
+Base HTTP response. All response classes accept `content`, `status_code`, `headers`,
+and optionally `media_type`.
+
+```python
+from FasterAPI import Response
+
+return Response(content=b"raw bytes", status_code=200, media_type="text/plain")
+```
+
+---
+
+### `JSONResponse`
+
+Serialises content with `msgspec.json.encode`.
+
+```python
+from FasterAPI import JSONResponse
+
+return JSONResponse({"key": "value"}, status_code=200)
+```
+
+---
+
+### `HTMLResponse`
+
+Sets `Content-Type: text/html`.
+
+```python
+from FasterAPI import HTMLResponse
+
+return HTMLResponse("Hello
")
+```
+
+---
+
+### `PlainTextResponse`
+
+Sets `Content-Type: text/plain`.
+
+```python
+from FasterAPI import PlainTextResponse
+
+return PlainTextResponse("OK")
+```
+
+---
+
+### `RedirectResponse`
+
+Issues an HTTP redirect.
+
+```python
+from FasterAPI import RedirectResponse
+
+return RedirectResponse(url="/new-path", status_code=307)
+```
+
+---
+
+### `StreamingResponse`
+
+Streams body from an async or sync iterator.
+
+```python
+from FasterAPI import StreamingResponse
+
+async def gen():
+ yield b"chunk1"
+ yield b"chunk2"
+
+return StreamingResponse(gen(), media_type="text/plain")
+```
+
+---
+
+### `FileResponse`
+
+Serves a file from disk with `Content-Disposition: attachment`.
+
+```python
+from FasterAPI import FileResponse
+
+return FileResponse("report.pdf", filename="report.pdf")
+```
+
+---
+
+## Parameters
+
+### `Path`
+
+Marks a parameter as coming from the URL path. Usage: `item_id: int = Path()`.
+
+### `Query`
+
+Marks a parameter as coming from the query string.
+
+```python
+from FasterAPI import Query
+
+async def search(q: str | None = Query(default=None, alias="search")): ...
+```
+
+### `Header`
+
+Marks a parameter as coming from a request header. Underscores in the parameter name
+are converted to hyphens by default (`convert_underscores=True`).
+
+```python
+from FasterAPI import Header
+
+async def handler(user_agent: str | None = Header(default=None)): ...
+# reads "User-Agent" header
+```
+
+### `Cookie`
+
+Marks a parameter as coming from a cookie.
+
+```python
+from FasterAPI import Cookie
+
+async def handler(session: str | None = Cookie(default=None)): ...
+```
+
+### `Body`
+
+Marks a parameter as coming from the raw JSON request body.
+
+```python
+from FasterAPI import Body
+
+async def handler(data: dict = Body()): ...
+```
+
+### `Form`
+
+Marks a parameter as coming from form data.
+
+```python
+from FasterAPI import Form
+
+async def login(username: str = Form(), password: str = Form()): ...
+```
+
+### `File`
+
+Marks a parameter as an uploaded file.
+
+```python
+from FasterAPI import File, UploadFile
+
+async def upload(file: UploadFile = File()): ...
+```
+
+---
+
+## Dependency Injection
+
+### `Depends`
+
+Declares a dependency to be resolved before the route handler.
+
+```python
+from FasterAPI import Depends
+
+async def get_db(): ...
+
+@app.get("/items")
+async def handler(db = Depends(get_db)): ...
+```
+
+Parameters:
+- `dependency` — callable to resolve
+- `use_cache=True` — if `True`, calls dependency once per request
+
+---
+
+## Exceptions
+
+### `HTTPException`
+
+```python
+from FasterAPI import HTTPException
+
+raise HTTPException(status_code=404, detail="Not found")
+raise HTTPException(status_code=401, headers={"WWW-Authenticate": "Bearer"})
+```
+
+### `RequestValidationError`
+
+Raised automatically when a path/query/body parameter fails validation.
+
+```python
+from FasterAPI.exceptions import RequestValidationError
+
+app.add_exception_handler(RequestValidationError, my_handler)
+```
+
+---
+
+## Middleware
+
+### `BaseHTTPMiddleware`
+
+Subclass to write custom middleware:
+
+```python
+from FasterAPI import BaseHTTPMiddleware
+
+class MyMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, scope, receive, send):
+ # before
+ await self.app(scope, receive, send)
+ # after
+```
+
+### `CORSMiddleware`
+
+```python
+from FasterAPI import CORSMiddleware
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+ allow_credentials=False,
+ max_age=600,
+)
+```
+
+### `GZipMiddleware`
+
+```python
+from FasterAPI import GZipMiddleware
+
+app.add_middleware(GZipMiddleware, minimum_size=1000)
+```
+
+### `TrustedHostMiddleware`
+
+```python
+from FasterAPI import TrustedHostMiddleware
+
+app.add_middleware(TrustedHostMiddleware, allowed_hosts=["example.com"])
+```
+
+### `HTTPSRedirectMiddleware`
+
+```python
+from FasterAPI import HTTPSRedirectMiddleware
+
+app.add_middleware(HTTPSRedirectMiddleware)
+```
+
+---
+
+## Background Tasks
+
+### `BackgroundTasks`
+
+```python
+from FasterAPI import BackgroundTasks
+
+@app.post("/items")
+async def create(tasks: BackgroundTasks):
+ tasks.add_task(send_email, "user@example.com")
+ return {"queued": True}
+```
+
+### `BackgroundTask`
+
+Single task wrapper:
+
+```python
+from FasterAPI import BackgroundTask
+
+task = BackgroundTask(send_email, "user@example.com")
+```
+
+---
+
+## WebSocket
+
+### `WebSocket`
+
+```python
+from FasterAPI import WebSocket
+
+@app.websocket("/ws")
+async def ws_handler(ws: WebSocket):
+ await ws.accept()
+ data = await ws.receive_text()
+ await ws.send_text(f"Echo: {data}")
+```
+
+Methods: `accept()`, `receive_text()`, `receive_bytes()`, `receive_json()`,
+`send_text()`, `send_bytes()`, `send_json()`, `close(code=1000)`
+
+### `WebSocketDisconnect`
+
+Exception raised when the client disconnects.
+
+### `WebSocketState`
+
+Enum: `CONNECTING`, `CONNECTED`, `DISCONNECTED`
+
+---
+
+## Data Structures
+
+### `UploadFile`
+
+Represents an uploaded file:
+
+| Attribute / method | Description |
+|--------------------|-------------|
+| `filename` | Original filename |
+| `content_type` | MIME type |
+| `await file.read()` | Read all bytes |
+
+### `FormData`
+
+Mapping-like object returned by `await request.form()`.
+
+---
+
+## Concurrency
+
+### `SubInterpreterPool`
+
+CPU-parallel worker pool using Python 3.13 sub-interpreters (falls back to
+`ProcessPoolExecutor` on earlier versions).
+
+```python
+from FasterAPI import SubInterpreterPool
+
+pool = SubInterpreterPool(max_workers=4)
+```
+
+### `run_in_subinterpreter`
+
+Run a function in a sub-interpreter and return an `asyncio.Future`:
+
+```python
+from FasterAPI import run_in_subinterpreter
+
+result = await run_in_subinterpreter(heavy_function, arg1, arg2)
+```
+
+---
+
+## Auto-generated docs
+
::: FasterAPI
diff --git a/docs/benchmarks.md b/docs/benchmarks.md
index 6d914ec..d6bb32a 100644
--- a/docs/benchmarks.md
+++ b/docs/benchmarks.md
@@ -1,8 +1,9 @@
# Benchmarks
-CI publishes numbers from **Python 3.13** on Linux; local runs may differ. Results depend on **hardware**, **Python version**, and **server settings**. Treat absolute
-**requests per second** as indicative; compare **ratios** (e.g. FasterAPI vs FastAPI on the
-same machine) for regressions.
+CI publishes numbers from **Python 3.13** on Linux; local runs may differ. Results
+depend on **hardware**, **Python version**, and **server settings**. Treat absolute
+**requests per second** as indicative; compare **ratios** (e.g. FasterAPI vs FastAPI
+on the same machine) for regressions.
## What we compare
@@ -13,37 +14,133 @@ same machine) for regressions.
Python frameworks are compared in two ways:
- **Direct ASGI:** invokes the ASGI callable in-process (no TCP stack). Stresses routing,
- validation, and serialization.
-- **HTTP (httpx):** same client load against **uvicorn** for Python and Fiber’s HTTP server.
+ validation, and serialisation.
+- **HTTP (httpx):** same client load against **uvicorn** for Python and Fiber's HTTP server.
Includes network stack cost on localhost.
+## Environment specification
+
+Reproducible benchmarks require a consistent environment. CI runs on:
+
+| Parameter | Value |
+|---|---|
+| OS | Ubuntu 22.04 (GitHub Actions `ubuntu-latest`) |
+| Python | 3.13 (CPython, official installer) |
+| CPU | GitHub Actions hosted runner (2 vCPU) |
+| Memory | 7 GB |
+| uvicorn | latest stable |
+| Concurrency | configurable via `--concurrency` flag (default 100) |
+| Requests | configurable via `--requests` flag (default 10 000) |
+
## Routing micro-benchmark
-The **radix tree** router is compared to a **regex** approach that mirrors many frameworks:
-many compiled patterns, match until one succeeds. The workload performs a fixed number of
-lookups on representative paths.
+The **radix tree** router is compared to a **regex** approach that mirrors many
+frameworks: many compiled patterns, match until one succeeds. The workload performs a
+fixed number of lookups on representative paths.
+
+Typical result (run `python benchmarks/compare.py --direct`):
+
+```
+Routing benchmark
+ Radix tree: X.XXx (FasterAPI)
+ Regex: 1.00x (baseline)
+ Speedup: X.XX×
+```
+
+The speedup varies with the number of registered routes — radix scales O(k) in key
+length while regex scales O(n) in number of patterns.
+
+## ASGI throughput benchmark
+
+In-process ASGI invocation measures the framework overhead without network latency:
+
+```
+ASGI throughput (req/s)
+ FasterAPI: XX,XXX req/s
+ FastAPI: XX,XXX req/s
+ Speedup: X.XX×
+```
+
+Key contributors to FasterAPI's advantage:
+
+- `msgspec` JSON encode/decode (C extension, ~2-10× faster than Pydantic's encoder)
+- Handler compilation at route-registration time (no `inspect.signature` per request)
+- Radix tree routing (O(k) vs O(n))
+- Optional uvloop event loop
+
+## HTTP benchmark (localhost)
+
+Running real uvicorn servers and measuring with httpx:
+
+```
+HTTP throughput (req/s, concurrency=100)
+ FasterAPI: XX,XXX req/s
+ FastAPI: XX,XXX req/s
+ Fiber (Go): XX,XXX req/s
+```
+
+Go Fiber shows the theoretical ceiling for this hardware — any Python framework sits
+below it due to interpreter overhead.
+
+## Regression floors
+
+Regression floors used in CI live in **`benchmarks/baseline.json`** and are enforced
+by **`benchmarks/check_regressions.py`**:
+
+```json
+{
+ "asgi_speedup_vs_fastapi": 1.5,
+ "routing_speedup_radix_vs_regex": 2.0
+}
+```
+
+A PR **fails** if either floor is breached.
## Reproduce locally
```bash
+# Install with benchmark extras
pip install -e ".[dev,benchmark]"
-# Direct ASGI + optional full HTTP comparison (starts local servers)
+
+# Direct ASGI benchmark (no network overhead)
python benchmarks/compare.py --direct
+
+# HTTP benchmark (starts local servers)
python benchmarks/compare.py --requests 10000 --concurrency 100
```
-**Fiber (Go):**
+**Fiber (Go) comparison:**
```bash
cd benchmarks/fiber
go build -o fiberbench .
-PORT=3099 ./fiberbench
+PORT=3099 ./fiberbench &
+python benchmarks/compare.py --requests 10000 --concurrency 100
```
-Regression floors used in CI live in **`benchmarks/baseline.json`** and are enforced by
-**`benchmarks/check_regressions.py`** (ASGI speedup vs FastAPI and radix-vs-regex speedup).
+## Interpreting results
+
+- **req/s** — requests per second (higher is better).
+- **Ratio** — FasterAPI req/s ÷ FastAPI req/s. A ratio of 2.0 means FasterAPI is
+ twice as fast on this workload.
+- **Latency percentiles** (p50, p95, p99) are more meaningful than throughput alone
+ for production sizing.
## CI
-Pull requests run the benchmark workflow: it **fails** if those floors are breached and posts
-a comment with the latest numbers including **Fiber** when the Go binary builds successfully.
+Pull requests run the benchmark workflow: it **fails** if regression floors are
+breached and posts a comment with the latest numbers including **Fiber** when the
+Go binary builds successfully.
+
+The benchmark results are also automatically synced to the README via the
+`sync-benchmark-readme` workflow on pushes to `master`.
+
+## Third-party verification
+
+For independent benchmarks of Python ASGI frameworks, see:
+
+- [TechEmpower Web Framework Benchmarks](https://www.techempower.com/benchmarks/)
+- [python-web-benchmarks](https://github.com/mtag-dev/py-web-frameworks-benchmarks)
+
+These measure different workloads and hardware; treat them as directional, not
+absolute.
diff --git a/docs/changelog.md b/docs/changelog.md
new file mode 100644
index 0000000..d40cca6
--- /dev/null
+++ b/docs/changelog.md
@@ -0,0 +1,23 @@
+# Changelog
+
+PyPI package **`faster-api-web`** versions match **git tags** on `master`
+(see the Release workflow). Runtime `FasterAPI.__version__` comes from installed
+package metadata.
+
+## 0.1.2 (2026-04-08)
+
+- Documentation site (MkDocs), migration guide, benchmark methodology, and expanded
+ CI gates (coverage ≥ 85%, benchmark floors, Codecov strict upload).
+- PR benchmarks now include **HTTP comparison** of FasterAPI, FastAPI, and
+ **Go Fiber** (`benchmarks/fiber`).
+- **`TestClient`** is loaded lazily so a minimal `pip install faster-api-web` does
+ not require **httpx** until you import `TestClient`.
+
+## 0.1.1
+
+- Earlier alpha releases and performance baselines.
+
+---
+
+For the full list of commits, see the
+[GitHub releases page](https://github.com/FasterApiWeb/FasterAPI/releases).
diff --git a/docs/concepts/async-await.md b/docs/concepts/async-await.md
new file mode 100644
index 0000000..b0100ef
--- /dev/null
+++ b/docs/concepts/async-await.md
@@ -0,0 +1,179 @@
+# Async / Await Primer
+
+FasterAPI is built on **asyncio** — Python's standard library for writing concurrent
+code using coroutines. This page explains the fundamentals you need to use FasterAPI
+effectively.
+
+## The problem async solves
+
+A traditional web server handles one request at a time per thread. While waiting for
+a database query or an external API call, the thread sits idle, wasting resources.
+
+**Async I/O** lets a single thread handle *thousands* of requests simultaneously by
+**yielding control** during waits instead of blocking.
+
+## Coroutines and `async def`
+
+An `async def` function is a **coroutine function**. Calling it returns a coroutine
+object; it does not execute immediately.
+
+```python
+async def fetch_data():
+ return 42
+
+coro = fetch_data() # coroutine object — not yet run
+result = await coro # now it runs and returns 42
+```
+
+`await` can only be used inside an `async def` function.
+
+## The event loop
+
+The event loop is the scheduler that runs coroutines. ASGI servers (uvicorn,
+hypercorn) manage the event loop for you.
+
+```python
+import asyncio
+
+async def main():
+ print("Hello")
+ await asyncio.sleep(1)
+ print("World")
+
+asyncio.run(main()) # starts the event loop
+```
+
+## `await` suspends, not blocks
+
+When your code hits `await some_io()`, it pauses *that coroutine* and lets the event
+loop run other tasks. When the I/O completes, the coroutine resumes.
+
+```python
+import asyncio
+import time
+
+async def task(name, delay):
+ print(f"{name} started")
+ await asyncio.sleep(delay) # yields control
+ print(f"{name} done")
+
+async def main():
+ start = time.perf_counter()
+ await asyncio.gather(
+ task("A", 1),
+ task("B", 1),
+ )
+ elapsed = time.perf_counter() - start
+ print(f"Total: {elapsed:.1f}s") # ~1s, not 2s
+
+asyncio.run(main())
+```
+
+Both tasks run concurrently — total time is ~1 second.
+
+## Route handlers in FasterAPI
+
+All FasterAPI route handlers should be `async def`:
+
+```python
+@app.get("/items")
+async def list_items():
+ data = await db.fetch("SELECT * FROM items") # non-blocking
+ return data
+```
+
+Sync handlers are also supported but they block the event loop:
+
+```python
+# Acceptable for very fast operations; problematic for I/O
+@app.get("/sync")
+def sync_handler():
+ return {"ok": True}
+```
+
+## CPU-bound work
+
+`asyncio` does **not** parallelise CPU-bound work — it only helps with I/O. For
+CPU-heavy tasks use:
+
+- `asyncio.get_running_loop().run_in_executor(None, cpu_func)` — thread pool
+- `asyncio.get_running_loop().run_in_executor(ProcessPoolExecutor(), cpu_func)` — process pool
+- `SubInterpreterPool` (Python 3.13) — see [Concurrency & Parallelism](concurrency.md)
+
+```python
+import asyncio
+from concurrent.futures import ProcessPoolExecutor
+
+executor = ProcessPoolExecutor()
+
+
+def heavy_computation(n: int) -> int:
+ return sum(range(n))
+
+
+@app.get("/compute")
+async def compute(n: int = 1_000_000):
+ loop = asyncio.get_running_loop()
+ result = await loop.run_in_executor(executor, heavy_computation, n)
+ return {"result": result}
+```
+
+## Common mistakes
+
+### Forgetting `await`
+
+```python
+# BAD — returns coroutine object, not the data
+data = db.fetch("SELECT ...")
+
+# GOOD
+data = await db.fetch("SELECT ...")
+```
+
+### Calling sync I/O from async context
+
+Sync calls (e.g. `requests.get(...)`, `time.sleep(...)`) **block the event loop**,
+starving all other concurrent requests.
+
+```python
+# BAD
+@app.get("/data")
+async def get_data():
+ import requests
+ r = requests.get("https://api.example.com/data") # blocks event loop!
+ return r.json()
+
+# GOOD
+@app.get("/data")
+async def get_data():
+ import httpx
+ async with httpx.AsyncClient() as client:
+ r = await client.get("https://api.example.com/data")
+ return r.json()
+```
+
+### Mixing `asyncio.run` inside a running event loop
+
+```python
+# BAD inside a route handler
+asyncio.run(some_coroutine()) # raises RuntimeError
+
+# GOOD — await directly
+await some_coroutine()
+```
+
+## Useful `asyncio` primitives
+
+| Primitive | Purpose |
+|---|---|
+| `asyncio.gather(*coros)` | Run coroutines concurrently, return all results |
+| `asyncio.wait_for(coro, timeout)` | Cancel if not done within timeout |
+| `asyncio.sleep(seconds)` | Non-blocking pause |
+| `asyncio.create_task(coro)` | Schedule coroutine in background |
+| `asyncio.Queue` | Producer-consumer pattern |
+| `asyncio.Lock` | Mutual exclusion for shared state |
+
+## Next steps
+
+- [Concurrency & Parallelism](concurrency.md) — how FasterAPI uses sub-interpreters.
+- [Background Tasks](../tutorial/background-tasks.md) — fire-and-forget after response.
diff --git a/docs/concepts/concurrency.md b/docs/concepts/concurrency.md
new file mode 100644
index 0000000..185ba9d
--- /dev/null
+++ b/docs/concepts/concurrency.md
@@ -0,0 +1,148 @@
+# Concurrency & Parallelism
+
+FasterAPI is designed to extract maximum performance from modern Python. This page
+explains *how* it achieves this and *when* to use each concurrency primitive.
+
+## The Python GIL
+
+Python's Global Interpreter Lock (GIL) ensures only one thread executes Python
+bytecode at a time. This limits true CPU parallelism within a single process.
+
+**async/await sidesteps the GIL** for I/O-bound work because the event loop
+cooperatively yields control while waiting — no threads needed.
+
+For **CPU-bound** tasks, the GIL is a real constraint. FasterAPI offers two
+solutions:
+
+1. **`SubInterpreterPool`** (Python 3.13) — true CPU parallelism in a single process.
+2. **`ProcessPoolExecutor`** (all Python versions) — multiple processes, each with
+ its own GIL.
+
+## FasterAPI's sub-interpreter parallelism
+
+Python 3.13 introduced **sub-interpreters** — isolated Python environments within the
+same OS process that each have their own GIL. FasterAPI's `SubInterpreterPool`
+distributes work across them.
+
+```python
+from FasterAPI import Faster, run_in_subinterpreter, SubInterpreterPool
+
+app = Faster()
+
+
+def cpu_heavy(data: bytes) -> bytes:
+ # runs in a sub-interpreter — does not block the main event loop
+ import hashlib
+ return hashlib.sha256(data).hexdigest().encode()
+
+
+@app.post("/hash")
+async def hash_data(request: Request):
+ body = await request.body()
+ result = await run_in_subinterpreter(cpu_heavy, body)
+ return {"hash": result.decode()}
+```
+
+### How `SubInterpreterPool` works
+
+1. A pool of worker threads is created at import time, each initialised with its own
+ sub-interpreter.
+2. `run_in_subinterpreter(func, *args)` serialises the function and arguments,
+ dispatches to a free worker, and returns an `asyncio.Future`.
+3. The worker runs `func(*args)` in its sub-interpreter (separate GIL → no blocking).
+4. The result is deserialised and the future is resolved on the main event loop.
+
+### Fallback on Python < 3.13
+
+On Python 3.10–3.12, `SubInterpreterPool` falls back to `ProcessPoolExecutor`:
+
+```python
+# concurrency.py
+try:
+ # Python 3.13 — true sub-interpreter parallelism
+ pool = SubInterpreterPool(max_workers=4)
+except RuntimeError:
+ # Fallback — process pool
+ from concurrent.futures import ProcessPoolExecutor
+ pool = ProcessPoolExecutor(max_workers=4)
+```
+
+## Event loop: uvloop
+
+On Linux, installing `uvloop` replaces the default asyncio event loop with a
+faster implementation (~2× faster I/O dispatch):
+
+```bash
+pip install faster-api-web[all] # includes uvloop
+```
+
+FasterAPI installs uvloop automatically at import time when it is available.
+
+## Choosing the right primitive
+
+| Work type | Recommended approach |
+|---|---|
+| I/O-bound (DB, network, file) | `async def` + `await` |
+| CPU-bound (hashing, encoding, ML inference) | `run_in_subinterpreter` |
+| CPU-bound, Python < 3.13 | `ProcessPoolExecutor` |
+| Blocking sync library | `asyncio.run_in_executor(None, func)` — thread pool |
+| Fire-and-forget I/O | `BackgroundTasks` |
+
+## Number of workers
+
+**uvicorn workers** — each handles requests concurrently via async I/O. A rule of
+thumb: `2 × CPU cores + 1`. With sub-interpreters, a single worker can use all
+cores for CPU work.
+
+```bash
+uvicorn main:app --workers 4
+```
+
+**`SubInterpreterPool` size** — defaults to the number of CPUs. Tune with:
+
+```python
+pool = SubInterpreterPool(max_workers=8)
+```
+
+## Concurrency in practice
+
+### Concurrent database queries
+
+```python
+import asyncio
+
+@app.get("/dashboard")
+async def dashboard():
+ users, items, orders = await asyncio.gather(
+ fetch_users(),
+ fetch_items(),
+ fetch_orders(),
+ )
+ return {"users": users, "items": items, "orders": orders}
+```
+
+Three DB queries run concurrently — total time ≈ max(query time), not sum.
+
+### Parallel CPU work
+
+```python
+@app.post("/batch-compress")
+async def batch_compress(files: list[bytes]):
+ results = await asyncio.gather(
+ *[run_in_subinterpreter(compress_one, f) for f in files]
+ )
+ return {"count": len(results)}
+```
+
+## Thread safety
+
+- **asyncio primitives** (`asyncio.Lock`, `asyncio.Queue`) — safe for async code.
+- **`threading.Lock`** — for sync code in thread-pool callbacks.
+- **Sub-interpreters** — isolated; do **not** share Python objects across interpreters.
+ Communicate via serialisable data (bytes, ints, strings).
+
+## Next steps
+
+- [Async / Await Primer](async-await.md) — fundamentals.
+- [Background Tasks](../tutorial/background-tasks.md) — defer I/O work.
+- [Benchmarks](../benchmarks.md) — measured throughput comparisons.
diff --git a/docs/concepts/types-intro.md b/docs/concepts/types-intro.md
new file mode 100644
index 0000000..c8fb943
--- /dev/null
+++ b/docs/concepts/types-intro.md
@@ -0,0 +1,152 @@
+# Python Type Hints Introduction
+
+FasterAPI relies heavily on Python **type hints** to declare parameter types,
+validate inputs, and generate OpenAPI documentation automatically.
+
+## What are type hints?
+
+Type hints are annotations that tell Python (and tools like mypy) the *intended* type
+of a variable, parameter, or return value. Python does **not** enforce them at
+runtime by default — FasterAPI uses them to do so for HTTP parameters.
+
+```python
+def greet(name: str) -> str:
+ return f"Hello, {name}"
+```
+
+## Basic types
+
+```python
+x: int = 42
+y: float = 3.14
+z: str = "hello"
+b: bool = True
+raw: bytes = b"\x00"
+```
+
+## Collections
+
+```python
+from typing import Any
+
+items: list[str] = ["a", "b"]
+mapping: dict[str, int] = {"x": 1}
+pair: tuple[int, str] = (1, "one")
+unique: set[int] = {1, 2, 3}
+anything: list[Any] = [1, "two", 3.0]
+```
+
+## Optional values
+
+```python
+# Python 3.10+
+name: str | None = None
+
+# Python 3.9 and earlier
+from typing import Optional
+name: Optional[str] = None
+```
+
+`str | None` means the value is either a `str` or `None`.
+
+## Union types
+
+```python
+# Python 3.10+
+value: int | str | float
+
+# Earlier versions
+from typing import Union
+value: Union[int, str, float]
+```
+
+## How FasterAPI uses type hints
+
+### Path and query parameters
+
+```python
+@app.get("/items/{item_id}")
+async def get_item(
+ item_id: int, # coerced from URL string to int
+ active: bool = True, # query param, default True
+ limit: int = 10, # query param, default 10
+):
+ ...
+```
+
+FasterAPI reads the annotations and automatically:
+- Extracts `item_id` from the URL and converts it to `int`.
+- Extracts `active` and `limit` from the query string.
+- Returns a **422** error with a clear message if coercion fails.
+
+### Request bodies with msgspec
+
+```python
+import msgspec
+
+class Item(msgspec.Struct):
+ name: str # required string
+ price: float # required float
+ tags: list[str] = [] # optional, defaults to empty list
+ description: str | None = None # optional, nullable
+```
+
+FasterAPI uses `msgspec`'s type information to validate and decode the JSON body.
+
+### Return types
+
+```python
+@app.get("/items/{item_id}")
+async def get_item(item_id: int) -> Item:
+ return Item(name="Widget", price=9.99)
+```
+
+The return type annotation drives OpenAPI schema generation for the response.
+
+## msgspec type support
+
+`msgspec` supports a rich set of types for struct fields:
+
+| Python type | JSON representation |
+|---|---|
+| `int`, `float` | number |
+| `str` | string |
+| `bool` | true / false |
+| `bytes` | base64-encoded string |
+| `list[T]`, `tuple[T, ...]` | array |
+| `dict[str, V]` | object |
+| `T \| None` | value or null |
+| Nested `Struct` | nested object |
+| `Literal["a", "b"]` | enum-like string |
+| `datetime` | ISO 8601 string |
+| `UUID` | UUID string |
+
+## Type checking with mypy
+
+Add mypy to your development workflow:
+
+```bash
+pip install mypy
+mypy main.py --strict
+```
+
+Or add to `pyproject.toml`:
+
+```toml
+[tool.mypy]
+strict = true
+```
+
+FasterAPI and msgspec both ship type stubs.
+
+## Type checking with ruff
+
+```bash
+pip install ruff
+ruff check .
+```
+
+## Next steps
+
+- [Request Body](../tutorial/request-body.md) — using msgspec structs in routes.
+- [Dependencies](../tutorial/dependencies.md) — type-annotated dependency injection.
diff --git a/docs/database/async-db.md b/docs/database/async-db.md
new file mode 100644
index 0000000..80236a6
--- /dev/null
+++ b/docs/database/async-db.md
@@ -0,0 +1,199 @@
+# Async Database Usage
+
+This page covers the patterns and pitfalls of using async databases in a FasterAPI
+application — applicable regardless of whether you use SQLAlchemy, Motor, or another
+library.
+
+## The request-scoped session pattern
+
+The most common pattern: open a session at the start of a request and close it
+at the end, even if the handler raises.
+
+```python
+# Works for SQLAlchemy, asyncpg, aiosqlite, etc.
+async def get_db():
+ session = SessionFactory()
+ try:
+ yield session
+ await session.commit() # commit on success
+ except Exception:
+ await session.rollback() # rollback on error
+ raise
+ finally:
+ await session.close() # always close
+```
+
+Use with `Depends`:
+
+```python
+@app.get("/items")
+async def list_items(db = Depends(get_db)):
+ ...
+```
+
+## Connection pool lifecycle
+
+Open the pool **once** at startup, close it **once** at shutdown. Never open a
+pool inside a route handler.
+
+```python
+import asyncpg
+from FasterAPI import Faster
+
+app = Faster()
+_pool: asyncpg.Pool | None = None
+
+
+@app.on_startup
+async def startup():
+ global _pool
+ _pool = await asyncpg.create_pool(
+ dsn=DATABASE_URL,
+ min_size=2,
+ max_size=10,
+ command_timeout=30,
+ )
+
+
+@app.on_shutdown
+async def shutdown():
+ if _pool:
+ await _pool.close()
+
+
+async def get_conn():
+ async with _pool.acquire() as conn:
+ yield conn
+```
+
+## Avoiding common mistakes
+
+### Never share a session across requests
+
+Each request must have its own session. A shared session leads to race conditions
+and corrupted state.
+
+```python
+# BAD — shared session
+session = SessionFactory()
+
+@app.get("/items")
+async def list_items():
+ return await session.execute(...) # not safe for concurrent requests
+```
+
+```python
+# GOOD — per-request session via Depends
+@app.get("/items")
+async def list_items(db = Depends(get_db)):
+ return await db.execute(...)
+```
+
+### Don't forget `await`
+
+Calling async methods without `await` returns a coroutine, not the result.
+
+```python
+# BAD
+result = db.execute(select(Item)) # returns coroutine, not rows
+
+# GOOD
+result = await db.execute(select(Item))
+```
+
+### Handle connection timeouts
+
+```python
+import asyncio
+
+async def get_db_with_timeout():
+ try:
+ async with asyncio.timeout(5):
+ async with SessionFactory() as session:
+ yield session
+ except asyncio.TimeoutError:
+ from FasterAPI import HTTPException
+ raise HTTPException(503, "Database unavailable")
+```
+
+## Read replicas
+
+Route read-heavy queries to a read replica:
+
+```python
+_write_pool: asyncpg.Pool | None = None
+_read_pool: asyncpg.Pool | None = None
+
+
+@app.on_startup
+async def startup():
+ global _write_pool, _read_pool
+ _write_pool = await asyncpg.create_pool(WRITE_DATABASE_URL)
+ _read_pool = await asyncpg.create_pool(READ_DATABASE_URL)
+
+
+async def get_write_conn():
+ async with _write_pool.acquire() as conn:
+ yield conn
+
+
+async def get_read_conn():
+ async with _read_pool.acquire() as conn:
+ yield conn
+
+
+@app.get("/items") # read — goes to replica
+async def list_items(db = Depends(get_read_conn)):
+ return await db.fetch("SELECT * FROM items")
+
+
+@app.post("/items") # write — goes to primary
+async def create_item(body: ItemCreate, db = Depends(get_write_conn)):
+ return await db.fetchrow("INSERT INTO items ...")
+```
+
+## Transactions
+
+Wrap multi-step operations in a transaction:
+
+```python
+@app.post("/transfer")
+async def transfer(from_id: int, to_id: int, amount: float, db = Depends(get_db)):
+ async with db.begin():
+ await db.execute(
+ "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
+ amount, from_id,
+ )
+ await db.execute(
+ "UPDATE accounts SET balance = balance + $1 WHERE id = $2",
+ amount, to_id,
+ )
+ return {"transferred": amount}
+```
+
+## Health check endpoint
+
+```python
+@app.get("/health/db")
+async def db_health(db = Depends(get_db)):
+ try:
+ await db.execute("SELECT 1")
+ return {"db": "ok"}
+ except Exception as exc:
+ raise HTTPException(503, f"Database error: {exc}")
+```
+
+## Tuning the pool
+
+| Parameter | Guidance |
+|---|---|
+| `min_size` | ≥ 1; keep connections warm |
+| `max_size` | Match worker count × 2–5; check DB max_connections |
+| `max_inactive_connection_lifetime` | Recycle idle connections (e.g. 300s) |
+| `command_timeout` | Fail fast on slow queries |
+
+## Next steps
+
+- [SQL with SQLAlchemy](sqlalchemy.md) — ORM-based SQL.
+- [NoSQL — MongoDB](nosql-mongodb.md) — document store.
+- [Lifespan Events](../advanced/lifespan.md) — startup / shutdown hooks.
diff --git a/docs/database/index.md b/docs/database/index.md
new file mode 100644
index 0000000..9c700cb
--- /dev/null
+++ b/docs/database/index.md
@@ -0,0 +1,68 @@
+# Database Integration
+
+FasterAPI is database-agnostic — use any Python library that works with async I/O.
+This section covers the most common choices.
+
+## Pages
+
+| Topic | What you learn |
+|---|---|
+| [SQL with SQLAlchemy](sqlalchemy.md) | Async SQLAlchemy 2, sessions, models, migrations |
+| [NoSQL — MongoDB](nosql-mongodb.md) | Motor (async MongoDB), documents, indices |
+| [Async Database Usage](async-db.md) | Pattern guide: sessions, pools, connection lifecycle |
+
+## Choosing a database library
+
+| Use case | Recommended |
+|---|---|
+| PostgreSQL / MySQL / SQLite | SQLAlchemy 2 (async) |
+| PostgreSQL (lightweight) | `asyncpg` directly |
+| MongoDB | Motor |
+| Redis (cache/queue) | `redis-py` (async) |
+| Key-value / embedded | `aiosqlite` for SQLite |
+
+## General pattern
+
+1. **Open a pool / session factory at startup** (see [Lifespan Events](../advanced/lifespan.md)).
+2. **Inject a session per request** using `Depends()`.
+3. **Close the session after the response** (use `try/finally` in the dependency).
+4. **Close the pool at shutdown**.
+
+```python
+# Generic pattern sketch
+@app.on_startup
+async def startup():
+ app_state.pool = await create_pool(DATABASE_URL)
+
+@app.on_shutdown
+async def shutdown():
+ await app_state.pool.close()
+
+async def get_session():
+ async with app_state.pool.acquire() as conn:
+ yield conn
+
+@app.get("/items")
+async def list_items(db = Depends(get_session)):
+ return await db.fetch("SELECT * FROM items")
+```
+
+## Environment variables
+
+Store the connection string in `DATABASE_URL`:
+
+```bash
+# PostgreSQL
+DATABASE_URL=postgresql+asyncpg://user:pass@localhost/mydb
+
+# SQLite (development)
+DATABASE_URL=sqlite+aiosqlite:///./dev.db
+
+# MongoDB
+MONGODB_URL=mongodb://localhost:27017
+```
+
+## Next steps
+
+- [Settings & Environment Variables](../advanced/settings.md) — managing `DATABASE_URL`.
+- [Lifespan Events](../advanced/lifespan.md) — startup / shutdown hooks for pools.
diff --git a/docs/database/nosql-mongodb.md b/docs/database/nosql-mongodb.md
new file mode 100644
index 0000000..eace921
--- /dev/null
+++ b/docs/database/nosql-mongodb.md
@@ -0,0 +1,201 @@
+# NoSQL — MongoDB with Motor
+
+**Motor** is the official async Python driver for MongoDB. It wraps PyMongo with
+asyncio support and integrates naturally with FasterAPI's dependency system.
+
+## Installation
+
+```bash
+pip install motor
+```
+
+## Connection
+
+```python
+# db/mongo.py
+import os
+from motor.motor_asyncio import AsyncIOMotorClient
+
+MONGODB_URL = os.environ.get("MONGODB_URL", "mongodb://localhost:27017")
+DB_NAME = os.environ.get("MONGODB_DB", "mydb")
+
+client: AsyncIOMotorClient | None = None
+
+
+def get_client() -> AsyncIOMotorClient:
+ if client is None:
+ raise RuntimeError("MongoDB client not initialised")
+ return client
+
+
+def get_database():
+ return get_client()[DB_NAME]
+```
+
+## Lifespan setup
+
+```python
+from FasterAPI import Faster
+import db.mongo as mongo
+
+app = Faster()
+
+
+@app.on_startup
+async def connect_mongo():
+ mongo.client = AsyncIOMotorClient(mongo.MONGODB_URL)
+ # Verify connection
+ await mongo.client.admin.command("ping")
+ print("MongoDB connected")
+
+
+@app.on_shutdown
+async def disconnect_mongo():
+ if mongo.client:
+ mongo.client.close()
+```
+
+## Collection dependency
+
+```python
+from FasterAPI import Depends
+from motor.motor_asyncio import AsyncIOMotorCollection
+
+
+def get_items_collection() -> AsyncIOMotorCollection:
+ return mongo.get_database()["items"]
+```
+
+## msgspec models
+
+```python
+import msgspec
+from bson import ObjectId
+
+
+class ItemCreate(msgspec.Struct):
+ name: str
+ price: float
+ tags: list[str] = []
+
+
+class Item(msgspec.Struct):
+ id: str
+ name: str
+ price: float
+ tags: list[str]
+```
+
+## CRUD routes
+
+```python
+from FasterAPI import Faster, Depends, HTTPException, Path
+from motor.motor_asyncio import AsyncIOMotorCollection
+
+app = Faster()
+
+
+def _doc_to_item(doc: dict) -> Item:
+ return Item(
+ id=str(doc["_id"]),
+ name=doc["name"],
+ price=doc["price"],
+ tags=doc.get("tags", []),
+ )
+
+
+@app.get("/items", tags=["items"])
+async def list_items(
+ col: AsyncIOMotorCollection = Depends(get_items_collection),
+):
+ docs = await col.find({}).to_list(length=100)
+ return [_doc_to_item(d) for d in docs]
+
+
+@app.post("/items", status_code=201, tags=["items"])
+async def create_item(
+ body: ItemCreate,
+ col: AsyncIOMotorCollection = Depends(get_items_collection),
+):
+ doc = {"name": body.name, "price": body.price, "tags": body.tags}
+ result = await col.insert_one(doc)
+ doc["_id"] = result.inserted_id
+ return _doc_to_item(doc)
+
+
+@app.get("/items/{item_id}", tags=["items"])
+async def get_item(
+ item_id: str = Path(),
+ col: AsyncIOMotorCollection = Depends(get_items_collection),
+):
+ from bson import ObjectId, errors as bson_errors
+ try:
+ oid = ObjectId(item_id)
+ except bson_errors.InvalidId:
+ raise HTTPException(status_code=400, detail="Invalid item ID")
+ doc = await col.find_one({"_id": oid})
+ if doc is None:
+ raise HTTPException(status_code=404, detail="Item not found")
+ return _doc_to_item(doc)
+
+
+@app.delete("/items/{item_id}", status_code=204, tags=["items"])
+async def delete_item(
+ item_id: str = Path(),
+ col: AsyncIOMotorCollection = Depends(get_items_collection),
+):
+ from bson import ObjectId
+ result = await col.delete_one({"_id": ObjectId(item_id)})
+ if result.deleted_count == 0:
+ raise HTTPException(status_code=404, detail="Item not found")
+```
+
+## Indexes
+
+Create indexes at startup for performance:
+
+```python
+@app.on_startup
+async def create_indexes():
+ col = mongo.get_database()["items"]
+ await col.create_index("name")
+ await col.create_index([("price", 1)])
+ await col.create_index("tags")
+```
+
+## Searching and filtering
+
+```python
+@app.get("/items/search", tags=["items"])
+async def search_items(
+ q: str | None = None,
+ max_price: float | None = None,
+ col: AsyncIOMotorCollection = Depends(get_items_collection),
+):
+ filters: dict = {}
+ if q:
+ filters["name"] = {"$regex": q, "$options": "i"}
+ if max_price is not None:
+ filters["price"] = {"$lte": max_price}
+ docs = await col.find(filters).to_list(length=50)
+ return [_doc_to_item(d) for d in docs]
+```
+
+## Aggregation pipeline
+
+```python
+@app.get("/stats", tags=["stats"])
+async def item_stats(
+ col: AsyncIOMotorCollection = Depends(get_items_collection),
+):
+ pipeline = [
+ {"$group": {"_id": None, "avg_price": {"$avg": "$price"}, "count": {"$sum": 1}}}
+ ]
+ result = await col.aggregate(pipeline).to_list(length=1)
+ return result[0] if result else {"avg_price": 0, "count": 0}
+```
+
+## Next steps
+
+- [Async Database Usage](async-db.md) — patterns for connection lifecycle.
+- [SQL with SQLAlchemy](sqlalchemy.md) — relational alternative.
diff --git a/docs/database/sqlalchemy.md b/docs/database/sqlalchemy.md
new file mode 100644
index 0000000..07be906
--- /dev/null
+++ b/docs/database/sqlalchemy.md
@@ -0,0 +1,207 @@
+# SQL Databases with SQLAlchemy
+
+This guide uses **SQLAlchemy 2** with an **async engine** and **asyncpg** (PostgreSQL)
+or **aiosqlite** (SQLite for development/testing).
+
+## Installation
+
+```bash
+# PostgreSQL
+pip install sqlalchemy asyncpg alembic
+
+# SQLite (dev / testing)
+pip install sqlalchemy aiosqlite
+```
+
+## Define models
+
+```python
+# db/models.py
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+from sqlalchemy import String, Float, Boolean
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+class ItemORM(Base):
+ __tablename__ = "items"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ price: Mapped[float] = mapped_column(Float, nullable=False)
+ in_stock: Mapped[bool] = mapped_column(Boolean, default=True)
+```
+
+## Engine and session factory
+
+```python
+# db/session.py
+import os
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./dev.db")
+
+engine = create_async_engine(DATABASE_URL, echo=False)
+AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+```
+
+## Lifespan — create tables at startup
+
+```python
+# main.py
+from FasterAPI import Faster
+from db.session import engine
+from db.models import Base
+
+app = Faster()
+
+
+@app.on_startup
+async def create_tables():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+
+@app.on_shutdown
+async def close_engine():
+ await engine.dispose()
+```
+
+## Session dependency
+
+```python
+# db/deps.py
+from collections.abc import AsyncGenerator
+from sqlalchemy.ext.asyncio import AsyncSession
+from db.session import AsyncSessionLocal
+
+
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
+ async with AsyncSessionLocal() as session:
+ try:
+ yield session
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
+```
+
+## msgspec structs for request/response
+
+Keep ORM models separate from API models:
+
+```python
+import msgspec
+
+
+class ItemCreate(msgspec.Struct):
+ name: str
+ price: float
+ in_stock: bool = True
+
+
+class ItemResponse(msgspec.Struct):
+ id: int
+ name: str
+ price: float
+ in_stock: bool
+```
+
+## CRUD routes
+
+```python
+from FasterAPI import Faster, Depends, HTTPException, Path
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from db.deps import get_db
+from db.models import ItemORM
+
+app = Faster()
+
+
+@app.get("/items", tags=["items"])
+async def list_items(db: AsyncSession = Depends(get_db)):
+ result = await db.execute(select(ItemORM))
+ items = result.scalars().all()
+ return [
+ ItemResponse(id=i.id, name=i.name, price=i.price, in_stock=i.in_stock)
+ for i in items
+ ]
+
+
+@app.post("/items", status_code=201, tags=["items"])
+async def create_item(body: ItemCreate, db: AsyncSession = Depends(get_db)):
+ item = ItemORM(name=body.name, price=body.price, in_stock=body.in_stock)
+ db.add(item)
+ await db.flush() # populate item.id without committing yet
+ return ItemResponse(id=item.id, name=item.name, price=item.price, in_stock=item.in_stock)
+
+
+@app.get("/items/{item_id}", tags=["items"])
+async def get_item(item_id: int = Path(), db: AsyncSession = Depends(get_db)):
+ item = await db.get(ItemORM, item_id)
+ if item is None:
+ raise HTTPException(status_code=404, detail="Item not found")
+ return ItemResponse(id=item.id, name=item.name, price=item.price, in_stock=item.in_stock)
+
+
+@app.delete("/items/{item_id}", status_code=204, tags=["items"])
+async def delete_item(item_id: int = Path(), db: AsyncSession = Depends(get_db)):
+ item = await db.get(ItemORM, item_id)
+ if item is None:
+ raise HTTPException(status_code=404, detail="Item not found")
+ await db.delete(item)
+```
+
+## Migrations with Alembic
+
+```bash
+alembic init alembic
+```
+
+Edit `alembic/env.py` to import your models and async engine:
+
+```python
+from db.models import Base
+from db.session import DATABASE_URL
+from sqlalchemy.ext.asyncio import create_async_engine
+
+target_metadata = Base.metadata
+```
+
+Create and apply a migration:
+
+```bash
+alembic revision --autogenerate -m "create items table"
+alembic upgrade head
+```
+
+## Testing with SQLite in-memory
+
+```python
+# tests/conftest.py
+import pytest
+import pytest_asyncio
+from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
+from db.models import Base
+
+TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
+
+
+@pytest_asyncio.fixture
+async def db():
+ engine = create_async_engine(TEST_DB_URL)
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ async with factory() as session:
+ yield session
+ await engine.dispose()
+```
+
+## Next steps
+
+- [Async Database Usage](async-db.md) — connection pool patterns.
+- [NoSQL — MongoDB](nosql-mongodb.md) — document databases.
diff --git a/docs/deployment/cloud.md b/docs/deployment/cloud.md
new file mode 100644
index 0000000..29db9dc
--- /dev/null
+++ b/docs/deployment/cloud.md
@@ -0,0 +1,195 @@
+# Cloud Deployment
+
+## AWS
+
+### AWS App Runner (simplest)
+
+App Runner builds from a container image or source code and handles scaling
+automatically.
+
+1. Push your Docker image to ECR:
+
+```bash
+aws ecr create-repository --repository-name my-fasterapi
+aws ecr get-login-password | docker login --username AWS --password-stdin \
+ .dkr.ecr..amazonaws.com
+docker tag my-fasterapi-app:latest \
+ .dkr.ecr..amazonaws.com/my-fasterapi:latest
+docker push .dkr.ecr..amazonaws.com/my-fasterapi:latest
+```
+
+2. Create an App Runner service:
+
+```bash
+aws apprunner create-service \
+ --service-name my-fasterapi \
+ --source-configuration '{
+ "ImageRepository": {
+ "ImageIdentifier": ".dkr.ecr..amazonaws.com/my-fasterapi:latest",
+ "ImageRepositoryType": "ECR"
+ }
+ }' \
+ --instance-configuration '{"Cpu": "1 vCPU", "Memory": "2 GB"}'
+```
+
+### AWS ECS (Fargate)
+
+ECS Fargate runs containers without managing servers.
+
+`task-definition.json`:
+
+```json
+{
+ "family": "fasterapi",
+ "networkMode": "awsvpc",
+ "requiresCompatibilities": ["FARGATE"],
+ "cpu": "512",
+ "memory": "1024",
+ "containerDefinitions": [
+ {
+ "name": "api",
+ "image": ".dkr.ecr..amazonaws.com/my-fasterapi:latest",
+ "portMappings": [{"containerPort": 8000}],
+ "environment": [
+ {"name": "ENV", "value": "production"}
+ ],
+ "secrets": [
+ {"name": "DATABASE_URL", "valueFrom": "arn:aws:secretsmanager:...:secret:db-url"},
+ {"name": "SECRET_KEY", "valueFrom": "arn:aws:secretsmanager:...:secret:sk"}
+ ],
+ "healthCheck": {
+ "command": ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"],
+ "interval": 30,
+ "timeout": 5,
+ "retries": 3
+ },
+ "logConfiguration": {
+ "logDriver": "awslogs",
+ "options": {
+ "awslogs-group": "/ecs/fasterapi",
+ "awslogs-region": "us-east-1",
+ "awslogs-stream-prefix": "ecs"
+ }
+ }
+ }
+ ]
+}
+```
+
+### AWS Lambda (serverless)
+
+Use **Mangum** to wrap the FasterAPI ASGI app for Lambda + API Gateway:
+
+```bash
+pip install mangum
+```
+
+```python
+# handler.py
+from mangum import Mangum
+from main import app
+
+handler = Mangum(app, lifespan="off")
+```
+
+Deploy with the Serverless Framework or AWS SAM.
+
+---
+
+## Google Cloud Platform
+
+### Cloud Run
+
+Cloud Run is the simplest managed container service on GCP.
+
+```bash
+# Build and push to Artifact Registry
+gcloud builds submit --tag gcr.io/PROJECT_ID/my-fasterapi
+
+# Deploy
+gcloud run deploy my-fasterapi \
+ --image gcr.io/PROJECT_ID/my-fasterapi \
+ --platform managed \
+ --region us-central1 \
+ --allow-unauthenticated \
+ --port 8000 \
+ --memory 512Mi \
+ --concurrency 80 \
+ --set-env-vars ENV=production \
+ --set-secrets DATABASE_URL=db-url:latest,SECRET_KEY=sk:latest
+```
+
+### Cloud Run with custom domain + TLS
+
+```bash
+gcloud run domain-mappings create \
+ --service my-fasterapi \
+ --domain api.example.com \
+ --region us-central1
+```
+
+---
+
+## Microsoft Azure
+
+### Azure Container Apps
+
+```bash
+# Create resource group and environment
+az group create --name my-rg --location eastus
+az containerapp env create --name my-env --resource-group my-rg --location eastus
+
+# Deploy
+az containerapp create \
+ --name my-fasterapi \
+ --resource-group my-rg \
+ --environment my-env \
+ --image myregistry.azurecr.io/my-fasterapi:latest \
+ --target-port 8000 \
+ --ingress external \
+ --min-replicas 1 \
+ --max-replicas 10 \
+ --env-vars ENV=production \
+ --secrets db-url=secretref:DATABASE_URL sk=secretref:SECRET_KEY
+```
+
+### Azure App Service
+
+```bash
+az webapp create \
+ --resource-group my-rg \
+ --plan my-plan \
+ --name my-fasterapi \
+ --deployment-container-image-name myregistry.azurecr.io/my-fasterapi:latest
+
+az webapp config appsettings set \
+ --resource-group my-rg \
+ --name my-fasterapi \
+ --settings DATABASE_URL="@Microsoft.KeyVault(SecretUri=...)" ENV=production
+```
+
+---
+
+## Secrets management
+
+| Platform | Service |
+|---|---|
+| AWS | Secrets Manager / Parameter Store |
+| GCP | Secret Manager |
+| Azure | Key Vault |
+| Any | HashiCorp Vault |
+
+Never bake secrets into container images or commit `.env` files.
+
+## Auto-scaling considerations
+
+- FasterAPI with uvicorn scales **horizontally** — add more replicas behind a load
+ balancer.
+- For CPU-bound work, use Python 3.13's `SubInterpreterPool` to parallelise within a
+ single process before adding replicas.
+- Session/state management (JWT is stateless; DB connections need a pool per replica).
+
+## Next steps
+
+- [Docker](docker.md) — containerise before deploying to cloud.
+- [Kubernetes](kubernetes.md) — advanced orchestration.
diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md
new file mode 100644
index 0000000..ace89d5
--- /dev/null
+++ b/docs/deployment/docker.md
@@ -0,0 +1,201 @@
+# Docker
+
+## Project Dockerfile
+
+FasterAPI ships a `Dockerfile` in the repository root. Here is a production-ready
+multi-stage build:
+
+```dockerfile
+# ── Build stage ─────────────────────────────────────────────────
+FROM python:3.13-slim AS builder
+
+WORKDIR /app
+
+# Install build tools
+RUN pip install --upgrade pip
+
+# Copy dependency files first to leverage layer cache
+COPY pyproject.toml ./
+RUN pip install --no-cache-dir ".[all]" --target /app/deps
+
+# ── Runtime stage ────────────────────────────────────────────────
+FROM python:3.13-slim
+
+WORKDIR /app
+
+# Copy installed packages from builder
+COPY --from=builder /app/deps /usr/local/lib/python3.13/site-packages
+
+# Copy application source
+COPY . .
+
+# Non-root user for security
+RUN useradd -r -u 1001 appuser
+USER appuser
+
+EXPOSE 8000
+
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
+```
+
+### Build and run
+
+```bash
+docker build -t my-fasterapi-app .
+docker run -p 8000:8000 \
+ -e DATABASE_URL=postgresql+asyncpg://user:pass@host/db \
+ -e SECRET_KEY=your-secret \
+ my-fasterapi-app
+```
+
+## .dockerignore
+
+```
+.git
+.venv
+__pycache__
+*.pyc
+*.pyo
+.pytest_cache
+.mypy_cache
+.ruff_cache
+*.egg-info
+dist/
+docs/
+tests/
+.env
+.env.*
+```
+
+## docker-compose for local development
+
+```yaml
+# docker-compose.yml
+version: "3.9"
+
+services:
+ api:
+ build: .
+ ports:
+ - "8000:8000"
+ environment:
+ DATABASE_URL: postgresql+asyncpg://postgres:postgres@db/mydb
+ SECRET_KEY: dev-secret-not-for-production
+ DEBUG: "true"
+ depends_on:
+ db:
+ condition: service_healthy
+ volumes:
+ - .:/app # live reload in development
+
+ db:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: mydb
+ ports:
+ - "5432:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "6379:6379"
+```
+
+```bash
+docker compose up --build
+```
+
+## docker-compose for production
+
+```yaml
+# docker-compose.prod.yml
+version: "3.9"
+
+services:
+ api:
+ image: my-fasterapi-app:${VERSION:-latest}
+ restart: unless-stopped
+ environment:
+ DATABASE_URL: ${DATABASE_URL}
+ SECRET_KEY: ${SECRET_KEY}
+ deploy:
+ replicas: 2
+ resources:
+ limits:
+ memory: 512m
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ nginx:
+ image: nginx:alpine
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+ - /etc/letsencrypt:/etc/letsencrypt:ro
+ depends_on:
+ - api
+```
+
+## Optimising image size
+
+Use `python:3.13-slim` (not the full image) and avoid installing dev dependencies:
+
+```dockerfile
+RUN pip install --no-cache-dir ".[all]"
+```
+
+Multi-stage builds keep the final image free of build tools:
+
+```bash
+docker images my-fasterapi-app
+# REPOSITORY TAG SIZE
+# my-fasterapi-app latest ~120MB
+```
+
+## Environment variable secrets
+
+In production, inject secrets via your orchestrator — never bake them into the image:
+
+```bash
+# Docker Swarm secrets
+docker secret create db_url ./db_url.txt
+
+# Kubernetes — see Kubernetes deployment guide
+```
+
+## Python 3.13 with sub-interpreters
+
+Use the official `python:3.13` image for sub-interpreter CPU parallelism. Set
+`GIL=1` only if libraries require it:
+
+```dockerfile
+FROM python:3.13
+ENV PYTHONGIL=0
+```
+
+## Health check
+
+Add a health check so Docker / orchestrators can restart unhealthy containers:
+
+```dockerfile
+HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
+ CMD curl -f http://localhost:8000/health || exit 1
+```
+
+## Next steps
+
+- [Nginx & Traefik](nginx-traefik.md) — TLS and load balancing.
+- [Kubernetes](kubernetes.md) — orchestrate at scale.
+- [Cloud Services](cloud.md) — managed container services.
diff --git a/docs/deployment/index.md b/docs/deployment/index.md
new file mode 100644
index 0000000..7c30ebf
--- /dev/null
+++ b/docs/deployment/index.md
@@ -0,0 +1,91 @@
+# Deployment
+
+This section covers deploying a FasterAPI application to production — from containers
+to cloud platforms to bare-metal servers.
+
+## Pages
+
+| Topic | What you learn |
+|---|---|
+| [Docker](docker.md) | Dockerfile, multi-stage builds, docker-compose |
+| [Nginx & Traefik](nginx-traefik.md) | Reverse proxy, TLS termination, load balancing |
+| [Cloud Services](cloud.md) | AWS, GCP, Azure deployment options |
+| [Kubernetes](kubernetes.md) | Manifests, health checks, rolling updates |
+
+## ASGI servers
+
+FasterAPI is an ASGI application; you need an ASGI server to run it:
+
+| Server | Notes |
+|---|---|
+| **uvicorn** | Recommended. Lightweight, production-ready. |
+| **hypercorn** | Supports HTTP/2 and HTTP/3. |
+| **daphne** | Django Channels' server; ASGI-native. |
+| **granian** | Rust-based; very fast. |
+
+### uvicorn (recommended)
+
+```bash
+pip install uvicorn[standard]
+
+# Development
+uvicorn main:app --reload
+
+# Production (multiple workers)
+uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
+```
+
+### Hypercorn
+
+```bash
+pip install hypercorn
+
+hypercorn main:app --bind 0.0.0.0:8000 --workers 4
+```
+
+Hypercorn supports HTTP/2 with TLS:
+
+```bash
+hypercorn main:app --bind 0.0.0.0:443 \
+ --keyfile key.pem --certfile cert.pem
+```
+
+### Daphne
+
+```bash
+pip install daphne
+
+daphne -b 0.0.0.0 -p 8000 main:app
+```
+
+## Number of workers
+
+A rule of thumb for CPU-bound workloads: **2 × CPU cores + 1**.
+For I/O-bound APIs, experiment with higher values.
+
+```bash
+uvicorn main:app --workers $(( 2 * $(nproc) + 1 ))
+```
+
+For Python 3.13 with `SubInterpreterPool`, a single uvicorn worker can leverage
+multiple CPU cores — see [Concurrency & Parallelism](../concepts/concurrency.md).
+
+## Environment variables
+
+Always configure the application through environment variables in production.
+See [Settings & Environment Variables](../advanced/settings.md).
+
+## Health checks
+
+Expose a `/health` endpoint for load balancers and container orchestrators:
+
+```python
+@app.get("/health")
+async def health():
+ return {"status": "ok"}
+```
+
+## Next steps
+
+- [Docker](docker.md) — containerise your app.
+- [Nginx & Traefik](nginx-traefik.md) — proxy and TLS.
diff --git a/docs/deployment/kubernetes.md b/docs/deployment/kubernetes.md
new file mode 100644
index 0000000..bf4197d
--- /dev/null
+++ b/docs/deployment/kubernetes.md
@@ -0,0 +1,242 @@
+# Kubernetes
+
+This guide deploys a FasterAPI application on Kubernetes with a Deployment,
+Service, Ingress, HPA, and health probes.
+
+## Prerequisites
+
+- A running Kubernetes cluster (EKS, GKE, AKS, or local `kind`/`minikube`)
+- `kubectl` configured
+- Your Docker image in a registry the cluster can pull from
+
+## Namespace
+
+```bash
+kubectl create namespace fasterapi
+```
+
+## ConfigMap and Secret
+
+```yaml
+# k8s/configmap.yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: fasterapi-config
+ namespace: fasterapi
+data:
+ ENV: "production"
+ WORKERS: "4"
+```
+
+```yaml
+# k8s/secret.yaml
+apiVersion: v1
+kind: Secret
+metadata:
+ name: fasterapi-secrets
+ namespace: fasterapi
+type: Opaque
+stringData:
+ DATABASE_URL: "postgresql+asyncpg://user:pass@postgres-svc/mydb"
+ SECRET_KEY: "your-production-secret-key"
+```
+
+```bash
+kubectl apply -f k8s/configmap.yaml
+kubectl apply -f k8s/secret.yaml
+```
+
+## Deployment
+
+```yaml
+# k8s/deployment.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: fasterapi
+ namespace: fasterapi
+spec:
+ replicas: 3
+ selector:
+ matchLabels:
+ app: fasterapi
+ strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxUnavailable: 1
+ maxSurge: 1
+ template:
+ metadata:
+ labels:
+ app: fasterapi
+ spec:
+ containers:
+ - name: api
+ image: myregistry/my-fasterapi:latest
+ imagePullPolicy: Always
+ ports:
+ - containerPort: 8000
+ envFrom:
+ - configMapRef:
+ name: fasterapi-config
+ - secretRef:
+ name: fasterapi-secrets
+ resources:
+ requests:
+ cpu: "250m"
+ memory: "256Mi"
+ limits:
+ cpu: "1000m"
+ memory: "512Mi"
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: 8000
+ initialDelaySeconds: 10
+ periodSeconds: 15
+ failureThreshold: 3
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 8000
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ failureThreshold: 3
+ startupProbe:
+ httpGet:
+ path: /health
+ port: 8000
+ failureThreshold: 30
+ periodSeconds: 3
+```
+
+## Service
+
+```yaml
+# k8s/service.yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: fasterapi-svc
+ namespace: fasterapi
+spec:
+ selector:
+ app: fasterapi
+ ports:
+ - port: 80
+ targetPort: 8000
+ type: ClusterIP
+```
+
+## Ingress (Nginx Ingress Controller)
+
+```yaml
+# k8s/ingress.yaml
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: fasterapi-ingress
+ namespace: fasterapi
+ annotations:
+ nginx.ingress.kubernetes.io/ssl-redirect: "true"
+ cert-manager.io/cluster-issuer: "letsencrypt-prod"
+spec:
+ tls:
+ - hosts:
+ - api.example.com
+ secretName: fasterapi-tls
+ rules:
+ - host: api.example.com
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: fasterapi-svc
+ port:
+ number: 80
+```
+
+## Horizontal Pod Autoscaler
+
+Scale based on CPU utilisation:
+
+```yaml
+# k8s/hpa.yaml
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: fasterapi-hpa
+ namespace: fasterapi
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: fasterapi
+ minReplicas: 2
+ maxReplicas: 20
+ metrics:
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: 70
+ - type: Resource
+ resource:
+ name: memory
+ target:
+ type: Utilization
+ averageUtilization: 80
+```
+
+## Apply all manifests
+
+```bash
+kubectl apply -f k8s/
+kubectl rollout status deployment/fasterapi -n fasterapi
+```
+
+## Rolling update
+
+```bash
+# Update image tag
+kubectl set image deployment/fasterapi api=myregistry/my-fasterapi:v2 -n fasterapi
+kubectl rollout status deployment/fasterapi -n fasterapi
+
+# Rollback if needed
+kubectl rollout undo deployment/fasterapi -n fasterapi
+```
+
+## Useful commands
+
+```bash
+# Logs
+kubectl logs -l app=fasterapi -n fasterapi --tail=100 -f
+
+# Shell into pod
+kubectl exec -it deploy/fasterapi -n fasterapi -- /bin/bash
+
+# Scale manually
+kubectl scale deployment fasterapi --replicas=5 -n fasterapi
+```
+
+## Helm chart
+
+For reusable parameterised deployments, package the manifests as a Helm chart:
+
+```bash
+helm create fasterapi-chart
+# Edit templates/ with the manifests above
+helm upgrade --install fasterapi ./fasterapi-chart \
+ --namespace fasterapi \
+ --set image.tag=v2 \
+ --set replicas=3
+```
+
+## Next steps
+
+- [Docker](docker.md) — build the image deployed here.
+- [Cloud Services](cloud.md) — managed Kubernetes on AWS/GCP/Azure.
diff --git a/docs/deployment/nginx-traefik.md b/docs/deployment/nginx-traefik.md
new file mode 100644
index 0000000..05a168e
--- /dev/null
+++ b/docs/deployment/nginx-traefik.md
@@ -0,0 +1,194 @@
+# Nginx & Traefik
+
+A reverse proxy in front of FasterAPI handles TLS termination, load balancing,
+rate limiting, and static file serving — letting uvicorn focus on Python.
+
+## Nginx
+
+### Basic configuration
+
+```nginx
+# /etc/nginx/conf.d/fasterapi.conf
+
+upstream fasterapi {
+ server 127.0.0.1:8000;
+ server 127.0.0.1:8001; # optional second worker
+ keepalive 16;
+}
+
+server {
+ listen 80;
+ server_name api.example.com;
+ return 301 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl http2;
+ server_name api.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+
+ # Security headers
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "DENY" always;
+
+ location / {
+ proxy_pass http://fasterapi;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 60s;
+ proxy_send_timeout 60s;
+ }
+
+ # SSE — disable buffering
+ location /events {
+ proxy_pass http://fasterapi;
+ proxy_set_header Connection "";
+ proxy_http_version 1.1;
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 3600s;
+ }
+
+ # WebSocket
+ location /ws {
+ proxy_pass http://fasterapi;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "Upgrade";
+ }
+}
+```
+
+### TLS with Certbot (Let's Encrypt)
+
+```bash
+sudo apt install certbot python3-certbot-nginx
+sudo certbot --nginx -d api.example.com
+# Auto-renewal
+sudo systemctl enable certbot.timer
+```
+
+### Rate limiting
+
+```nginx
+http {
+ limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
+
+ server {
+ location / {
+ limit_req zone=api burst=20 nodelay;
+ proxy_pass http://fasterapi;
+ }
+ }
+}
+```
+
+### Gzip (if not using FasterAPI's `GZipMiddleware`)
+
+```nginx
+gzip on;
+gzip_types application/json text/plain text/css;
+gzip_min_length 1000;
+```
+
+## Traefik
+
+Traefik is a cloud-native reverse proxy that auto-discovers services via Docker
+labels or Kubernetes annotations.
+
+### docker-compose with Traefik
+
+```yaml
+# docker-compose.yml
+version: "3.9"
+
+services:
+ traefik:
+ image: traefik:v3
+ command:
+ - "--api.insecure=false"
+ - "--providers.docker=true"
+ - "--providers.docker.exposedbydefault=false"
+ - "--entrypoints.web.address=:80"
+ - "--entrypoints.websecure.address=:443"
+ - "--certificatesresolvers.le.acme.httpchallenge=true"
+ - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
+ - "--certificatesresolvers.le.acme.email=admin@example.com"
+ - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - ./letsencrypt:/letsencrypt
+
+ api:
+ image: my-fasterapi-app
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.api.rule=Host(`api.example.com`)"
+ - "traefik.http.routers.api.entrypoints=websecure"
+ - "traefik.http.routers.api.tls.certresolver=le"
+ - "traefik.http.services.api.loadbalancer.server.port=8000"
+ # Redirect HTTP → HTTPS
+ - "traefik.http.routers.api-http.rule=Host(`api.example.com`)"
+ - "traefik.http.routers.api-http.entrypoints=web"
+ - "traefik.http.routers.api-http.middlewares=redirect-to-https"
+ - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
+```
+
+### Rate limiting with Traefik middleware
+
+```yaml
+labels:
+ - "traefik.http.middlewares.ratelimit.ratelimit.average=100"
+ - "traefik.http.middlewares.ratelimit.ratelimit.burst=50"
+ - "traefik.http.routers.api.middlewares=ratelimit"
+```
+
+### Static Traefik config (traefik.yml)
+
+```yaml
+entryPoints:
+ web:
+ address: ":80"
+ websecure:
+ address: ":443"
+
+certificatesResolvers:
+ le:
+ acme:
+ email: admin@example.com
+ storage: /letsencrypt/acme.json
+ httpChallenge:
+ entryPoint: web
+
+providers:
+ docker:
+ exposedByDefault: false
+```
+
+## FasterAPI root path
+
+Tell FasterAPI the URL prefix used by the proxy (affects Swagger UI server URL):
+
+```bash
+uvicorn main:app --root-path /api
+```
+
+Or via middleware — see [Behind a Proxy](../advanced/behind-proxy.md).
+
+## Next steps
+
+- [Kubernetes](kubernetes.md) — scale beyond single-host deployments.
+- [Cloud Services](cloud.md) — managed proxies on AWS / GCP / Azure.
diff --git a/docs/faq.md b/docs/faq.md
new file mode 100644
index 0000000..a557f0a
--- /dev/null
+++ b/docs/faq.md
@@ -0,0 +1,214 @@
+# FAQ & Troubleshooting
+
+## Installation
+
+### `pip install faster-api-web` succeeds but `import FasterAPI` fails
+
+Make sure you are importing with the correct capitalisation:
+
+```python
+from FasterAPI import Faster # capital F, API
+```
+
+The PyPI package name is `faster-api-web`; the Python package directory is `FasterAPI`.
+
+### `ImportError: TestClient requires httpx`
+
+```bash
+pip install httpx
+# or
+pip install faster-api-web[test]
+```
+
+### uvloop is not being used
+
+```bash
+pip install faster-api-web[all]
+```
+
+uvloop is only installed as an optional extra and only activates on Linux.
+
+---
+
+## Running the app
+
+### `uvicorn main:app --reload` — `app` not found
+
+Make sure your file is named `main.py` and it contains `app = Faster()`.
+
+### Port already in use
+
+```bash
+uvicorn main:app --port 8001
+# or find and kill the process
+lsof -ti:8000 | xargs kill -9
+```
+
+### Swagger UI (`/docs`) shows no routes
+
+Routes must be registered **before** the first request. If you use `include_router`,
+call it before uvicorn starts, not inside a handler.
+
+---
+
+## Request handling
+
+### 422 Unprocessable Entity on valid-looking input
+
+Check:
+1. `Content-Type: application/json` header is set.
+2. The JSON matches the struct field types exactly (e.g. `int` vs `str`).
+3. Required fields are present and not `null`.
+
+```bash
+curl -X POST /items \
+ -H "Content-Type: application/json" \
+ -d '{"name":"Widget","price":9.99}'
+```
+
+### Path parameter is always `None`
+
+Path parameters must appear in the URL pattern:
+
+```python
+@app.get("/items/{item_id}") # {item_id} in the path
+async def get_item(item_id: int):
+ ...
+```
+
+### Query parameter not received
+
+Check the URL encoding. Spaces should be `%20` or `+`.
+
+```bash
+curl "http://localhost:8000/search?q=hello%20world"
+```
+
+### File upload returns `422`
+
+Make sure the client sends `multipart/form-data` and the field name matches:
+
+```python
+@app.post("/upload")
+async def upload(file: UploadFile = File()): # field name is "file"
+ ...
+```
+
+```bash
+curl -X POST /upload -F "file=@photo.jpg"
+```
+
+---
+
+## Middleware
+
+### CORS preflight fails
+
+Add `CORSMiddleware` before any routes are hit:
+
+```python
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["https://yourfrontend.com"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+```
+
+### Middleware order matters
+
+Middleware is applied in **reverse registration order**. Add more-specific middleware
+last (it will run first):
+
+```python
+app.add_middleware(CORSMiddleware, ...) # outer (registered first)
+app.add_middleware(TimingMiddleware) # inner (registered last)
+```
+
+---
+
+## Dependencies
+
+### `Depends()` result is not cached between calls
+
+By default, `Depends` **is** cached per request. If you see it called multiple
+times, check that you're passing the same callable (not a lambda):
+
+```python
+# BAD — new lambda each time, never cached
+Depends(lambda: get_db())
+
+# GOOD
+Depends(get_db)
+```
+
+### Circular dependency
+
+FasterAPI does not detect circular `Depends()` chains. If you get a `RecursionError`,
+look for A → B → A dependency cycles.
+
+---
+
+## Database
+
+### `asyncpg` / SQLAlchemy session errors in tests
+
+Each test must use its own session. Share a session factory, not a session.
+
+### `RuntimeError: Task attached to a different loop`
+
+Do not share asyncio objects (like connections) between `asyncio.run()` calls.
+Use `on_startup` / `on_shutdown` to manage the lifecycle within a single event loop.
+
+---
+
+## Performance
+
+### Throughput is lower than expected
+
+1. Enable uvloop: `pip install uvicorn[standard]` or `pip install faster-api-web[all]`.
+2. Use multiple workers: `uvicorn main:app --workers 4`.
+3. Profile with `py-spy` or `cProfile` to find bottlenecks.
+4. Check if database queries are the bottleneck (add `EXPLAIN ANALYZE`).
+
+### High memory usage
+
+1. Check for memory leaks in background tasks.
+2. Ensure database connections are properly released (`async with session`).
+3. Profile with `tracemalloc` or `memray`.
+
+---
+
+## OpenAPI / Swagger UI
+
+### Swagger UI is blank or shows errors
+
+1. Visit `/openapi.json` directly and check for JSON syntax errors.
+2. Ensure `docs_url` and `openapi_url` are not set to `None`.
+3. Check browser console for CORS errors if the UI is hosted separately.
+
+### Response schema is missing or wrong
+
+Make sure the return type annotation is a `msgspec.Struct` or a supported primitive
+type. `dict` return types generate a generic object schema.
+
+---
+
+## WebSockets
+
+### WebSocket connection immediately closes
+
+The handler must `await ws.accept()` before sending or receiving data.
+
+### WebSocket 4004 error
+
+No WebSocket route matched the requested path. Check path spelling and trailing
+slashes.
+
+---
+
+## Getting help
+
+- **GitHub Issues:** [github.com/FasterApiWeb/FasterAPI/issues](https://github.com/FasterApiWeb/FasterAPI/issues)
+- **Source code:** Browse [`FasterAPI/`](https://github.com/FasterApiWeb/FasterAPI/tree/master/FasterAPI) on GitHub.
+- **Changelog:** See [CHANGELOG](changelog.md) for recent changes.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index b0a0b8b..dad1edd 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,7 +1,8 @@
-# Getting started
+# Getting Started
-Use **Python 3.13** if you can (see [Python 3.13 & compatibility](python-313.md)); the minimum
-supported release is **3.10**. Create a **virtual environment** before installing dependencies.
+Use **Python 3.13** if you can (see [Python 3.13 & compatibility](python-313.md)); the
+minimum supported release is **3.10**. Create a **virtual environment** before
+installing dependencies.
## Install
@@ -21,6 +22,12 @@ For **`TestClient`** (integration tests), add **httpx**:
pip install faster-api-web[test]
```
+For development (tests, linting, benchmarks):
+
+```bash
+pip install faster-api-web[dev]
+```
+
## Minimal application
Create `main.py`:
@@ -53,15 +60,78 @@ async def create_item(item: Item):
uvicorn main:app --reload
```
-Open `http://127.0.0.1:8000/docs` for the interactive OpenAPI UI (if enabled).
+You should see:
+
+```
+INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
+INFO: Started reloader process [...]
+INFO: Started server process [...]
+INFO: Waiting for application startup.
+INFO: Application startup complete.
+```
+
+## Try it with curl
+
+```bash
+# GET request
+curl http://127.0.0.1:8000/items/42
+# {"item_id":"42"}
+
+# POST request with JSON body
+curl -X POST http://127.0.0.1:8000/items \
+ -H "Content-Type: application/json" \
+ -d '{"name":"Widget","price":9.99}'
+# {"received":{"name":"Widget","price":9.99}}
+```
+
+## Interactive API docs
+
+Open `http://127.0.0.1:8000/docs` for the **Swagger UI** — automatically generated
+from your route definitions.
+
+From the Swagger UI you can:
+
+- Browse all endpoints grouped by tags
+- Click **Try it out** on any route to send a live request
+- See the full request and response schema
+
+Alternative docs are available at `http://127.0.0.1:8000/redoc`.
## Imports at a glance
-- **Application class:** `from FasterAPI import Faster` (or `from FasterAPI.app import Faster`).
-- **Path/query/body helpers:** `Path`, `Query`, `Body`, … from `FasterAPI`.
-- **Models:** use **`msgspec.Struct`**, not Pydantic, for validated JSON bodies by default.
+| What you need | Import |
+|---|---|
+| Application class | `from FasterAPI import Faster` |
+| Router (sub-router) | `from FasterAPI import FasterRouter` |
+| Request body helpers | `from FasterAPI import Body, Form, File` |
+| URL / query helpers | `from FasterAPI import Path, Query, Header, Cookie` |
+| Responses | `from FasterAPI import JSONResponse, HTMLResponse, FileResponse, …` |
+| Models | `import msgspec` — use `msgspec.Struct` |
+| DI | `from FasterAPI import Depends` |
+| Exceptions | `from FasterAPI import HTTPException` |
+| Middleware | `from FasterAPI import CORSMiddleware, GZipMiddleware, …` |
+| WebSocket | `from FasterAPI import WebSocket, WebSocketDisconnect` |
+| Background tasks | `from FasterAPI import BackgroundTasks` |
+| Testing | `from FasterAPI import TestClient` |
+
+## Project structure (recommended)
+
+```
+myproject/
+├── main.py # app = Faster(), include_router calls
+├── routers/
+│ ├── items.py # FasterRouter for items
+│ └── users.py # FasterRouter for users
+├── models.py # msgspec.Struct definitions
+├── dependencies.py # Shared Depends() callables
+└── tests/
+ ├── conftest.py
+ └── test_items.py
+```
## Next steps
-- Follow the [CRUD tutorial](tutorial-crud.md).
+- Follow the full [Tutorial](tutorial/index.md).
+- Start with [Path Parameters](tutorial/path-parameters.md) → [Query Parameters](tutorial/query-parameters.md) → [Request Body](tutorial/request-body.md).
- If you already use FastAPI, read [Migrating from FastAPI](migration-from-fastapi.md).
+- For performance context, see the [Benchmarks](benchmarks.md) page.
diff --git a/docs/how-to/index.md b/docs/how-to/index.md
new file mode 100644
index 0000000..1023a8f
--- /dev/null
+++ b/docs/how-to/index.md
@@ -0,0 +1,203 @@
+# How-To Recipes
+
+Short, focused recipes for common patterns.
+
+---
+
+## Return a plain dict without a struct
+
+```python
+@app.get("/info")
+async def info():
+ return {"version": "1.0", "status": "ok"}
+```
+
+---
+
+## Add a global prefix to all routes
+
+Use `include_router` with a prefix on all routers:
+
+```python
+app.include_router(router, prefix="/api/v1")
+```
+
+Or mount a sub-app:
+
+```python
+# All routes in 'api_app' are accessible under /api
+# (requires ASGI-level mounting)
+```
+
+---
+
+## Redirect to another URL
+
+```python
+from FasterAPI import RedirectResponse
+
+@app.get("/old")
+async def old_route():
+ return RedirectResponse(url="/new", status_code=301)
+```
+
+---
+
+## Return a file download
+
+```python
+from FasterAPI import FileResponse
+
+@app.get("/download/{filename}")
+async def download(filename: str):
+ return FileResponse(f"files/{filename}", filename=filename)
+```
+
+---
+
+## Accept optional JSON body
+
+```python
+@app.patch("/items/{item_id}")
+async def partial_update(item_id: int, body: ItemPatch | None = None):
+ if body is None:
+ return {"item_id": item_id, "updated": False}
+ return {"item_id": item_id, "name": body.name}
+```
+
+---
+
+## Add a request ID to every response
+
+```python
+import uuid
+from FasterAPI import BaseHTTPMiddleware
+
+class RequestIDMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, scope, receive, send):
+ request_id = str(uuid.uuid4())
+
+ async def add_header(message):
+ if message["type"] == "http.response.start":
+ headers = list(message.get("headers", []))
+ headers.append((b"x-request-id", request_id.encode()))
+ message = {**message, "headers": headers}
+ await send(message)
+
+ await self.app(scope, receive, add_header)
+
+app.add_middleware(RequestIDMiddleware)
+```
+
+---
+
+## Parse a comma-separated query parameter
+
+```python
+@app.get("/items")
+async def list_items(ids: str | None = None):
+ id_list = [int(x) for x in ids.split(",")] if ids else []
+ return {"ids": id_list}
+```
+
+```
+GET /items?ids=1,2,3
+```
+
+---
+
+## Validate an enum field
+
+```python
+from enum import Enum
+
+class Category(str, Enum):
+ electronics = "electronics"
+ clothing = "clothing"
+ food = "food"
+
+@app.get("/products")
+async def products(category: Category = Category.electronics):
+ return {"category": category}
+```
+
+---
+
+## Return 204 No Content
+
+```python
+@app.delete("/items/{item_id}", status_code=204)
+async def delete_item(item_id: int) -> None:
+ # perform deletion
+ pass
+```
+
+---
+
+## Health check with database probe
+
+```python
+@app.get("/health")
+async def health(db = Depends(get_db)):
+ try:
+ await db.execute("SELECT 1")
+ return {"status": "ok", "db": "ok"}
+ except Exception as exc:
+ raise HTTPException(503, f"DB error: {exc}")
+```
+
+---
+
+## Decode a JWT manually without a library
+
+```python
+import base64
+import json
+
+def decode_jwt_payload(token: str) -> dict:
+ """Decode JWT payload without verification — for inspection only."""
+ parts = token.split(".")
+ if len(parts) != 3:
+ return {}
+ padded = parts[1] + "==" # re-pad base64
+ decoded = base64.urlsafe_b64decode(padded)
+ return json.loads(decoded)
+```
+
+---
+
+## Serve a single-page application (SPA)
+
+```python
+@app.get("/{full_path:path}")
+async def spa_fallback(full_path: str):
+ return FileResponse("static/index.html")
+```
+
+---
+
+## Log request and response timing
+
+```python
+import time, logging
+
+logger = logging.getLogger(__name__)
+
+class TimingMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, scope, receive, send):
+ start = time.perf_counter()
+ await self.app(scope, receive, send)
+ elapsed_ms = (time.perf_counter() - start) * 1000
+ path = scope.get("path", "")
+ logger.info("%.1f ms %s", elapsed_ms, path)
+
+app.add_middleware(TimingMiddleware)
+```
+
+---
+
+## Next steps
+
+- [Tutorial](../tutorial/index.md) — step-by-step guide.
+- [Advanced User Guide](../advanced/index.md) — deeper topics.
+- [FAQ](../faq.md) — troubleshooting.
diff --git a/docs/migration-from-fastapi.md b/docs/migration-from-fastapi.md
index 1c3204f..b0381f3 100644
--- a/docs/migration-from-fastapi.md
+++ b/docs/migration-from-fastapi.md
@@ -4,18 +4,23 @@ FasterAPI is **not** a line-for-line fork of FastAPI, but many concepts map dire
Plan on touching **imports**, **model types**, and any code that assumed **Pydantic** or
**Starlette** internals.
-## 1. Install and import
+## Quick reference
| FastAPI | FasterAPI |
|--------|-----------|
| `pip install fastapi` | `pip install faster-api-web` |
| `from fastapi import FastAPI` | `from FasterAPI import Faster` |
| `app = FastAPI()` | `app = Faster()` |
+| `from fastapi import APIRouter` | `from FasterAPI import FasterRouter` |
+| `from fastapi.testclient import TestClient` | `from FasterAPI import TestClient` |
+| `from pydantic import BaseModel` | `import msgspec; class M(msgspec.Struct)` |
+| `from starlette.requests import Request` | `from FasterAPI import Request` |
+| `from fastapi.responses import JSONResponse` | `from FasterAPI import JSONResponse` |
-The PyPI distribution name is **`faster-api-web`**. The Python package directory is **`FasterAPI`**
-(capital **F** and **API**).
+The PyPI distribution name is **`faster-api-web`**. The Python package directory is
+**`FasterAPI`** (capital **F** and **API**).
-## 2. Replace Pydantic models with msgspec
+## 1. Replace Pydantic models with msgspec
Validation and JSON encoding use **msgspec** structs:
@@ -26,6 +31,7 @@ from pydantic import BaseModel
class User(BaseModel):
name: str
email: str
+ age: int | None = None
```
```python
@@ -35,42 +41,209 @@ import msgspec
class User(msgspec.Struct):
name: str
email: str
+ age: int | None = None
```
-**Field defaults**, **optional** fields, and **nested** structs work with msgspec’s usual rules.
-If you relied on Pydantic-specific validators (`@field_validator`) or complex JSON Schema,
-reimplement that logic with plain Python or narrow msgspec types.
+**Field defaults**, **optional** fields, and **nested** structs work with msgspec's
+usual rules.
-## 3. Routing and decorators
+### Pydantic-specific features and equivalents
-`@app.get`, `@app.post`, `APIRouter`-style grouping, path parameters, and `Depends()` are
-intended to feel familiar. Differences tend to be **edge cases** (custom Starlette routes,
-advanced middleware ordering). Port routes one module at a time and run tests.
+| Pydantic | msgspec equivalent |
+|---|---|
+| `@field_validator` | Plain function validation before/after constructing the struct |
+| `model_config = ConfigDict(...)` | `msgspec.Struct`'s class kwargs (e.g. `frozen=True`) |
+| `model.model_dump()` | `msgspec.structs.asdict(model)` |
+| `model.model_dump_json()` | `msgspec.json.encode(model)` |
+| `Model.model_validate(data)` | `msgspec.json.decode(data, type=Model)` |
+| `Field(ge=0, le=100)` | `msgspec.Meta(ge=0, le=100)` with `Annotated` |
+| `@computed_field` | Regular `@property` (not in OpenAPI schema) |
-## 4. Exceptions and responses
+### Annotated constraints
-`HTTPException`, JSON responses, redirects, and file responses have near-equivalent types
-under `FasterAPI`. Check response **headers** and **status codes** in integration tests after
-migration.
+```python
+from typing import Annotated
+import msgspec
+
+Price = Annotated[float, msgspec.Meta(ge=0)]
+Name = Annotated[str, msgspec.Meta(min_length=1, max_length=100)]
+
+
+class Product(msgspec.Struct):
+ name: Name
+ price: Price
+```
+
+## 2. Routing and decorators
+
+`@app.get`, `@app.post`, `@app.put`, `@app.delete`, `@app.patch`, `@app.websocket`,
+`APIRouter`-style grouping, path parameters, and `Depends()` are designed to feel
+familiar.
+
+```python
+# FastAPI
+from fastapi import APIRouter
+router = APIRouter()
+
+# FasterAPI
+from FasterAPI import FasterRouter
+router = FasterRouter()
+```
+
+`include_router` works the same:
+
+```python
+app.include_router(router, prefix="/items", tags=["items"])
+```
+
+## 3. Exceptions and responses
+
+`HTTPException`, JSON responses, redirects, and file responses have near-equivalent
+types under `FasterAPI`. Differences:
+
+- `HTTPException.headers` is a `dict[str, str]` in both frameworks.
+- `RequestValidationError.errors` is a list of dicts (same structure).
+- `Response` subclasses accept `headers: dict[str, str]` (same as FastAPI).
+
+```python
+# Same in both
+raise HTTPException(status_code=404, detail="Not found")
+raise HTTPException(status_code=401, headers={"WWW-Authenticate": "Bearer"})
+```
+
+## 4. OpenAPI and docs
+
+OpenAPI generation is supported; field metadata may differ slightly from Pydantic's
+schema extras. Regenerate your client or inspect `/openapi.json` after switching.
+
+```python
+# Both support the same constructor arguments
+app = Faster(
+ title="My API",
+ description="...",
+ version="1.0.0",
+ docs_url="/docs",
+ redoc_url="/redoc",
+)
+```
+
+## 5. Testing
+
+Use **`TestClient`** from **`FasterAPI`** (httpx-based), similar to FastAPI's test
+client:
+
+```python
+# FastAPI
+from fastapi.testclient import TestClient
+
+# FasterAPI
+from FasterAPI import TestClient # requires: pip install httpx
+```
+
+## 6. Background tasks
+
+```python
+# FastAPI
+from fastapi import BackgroundTasks
+
+# FasterAPI
+from FasterAPI import BackgroundTasks
+```
+
+Usage is identical — declare `BackgroundTasks` as a parameter:
+
+```python
+@app.post("/items")
+async def create(tasks: BackgroundTasks):
+ tasks.add_task(send_email, "user@example.com")
+ return {"status": "queued"}
+```
+
+## 7. Middleware
+
+```python
+# FastAPI (via Starlette)
+from starlette.middleware.cors import CORSMiddleware
+
+# FasterAPI
+from FasterAPI import CORSMiddleware
+
+app.add_middleware(CORSMiddleware, allow_origins=["*"])
+```
+
+## 8. WebSockets
+
+```python
+# FastAPI
+from fastapi import WebSocket
+
+# FasterAPI
+from FasterAPI import WebSocket
+```
+
+## Common migration gotchas
+
+### `Annotated` parameters
+
+FastAPI uses `Annotated[int, Path(ge=1)]`; FasterAPI uses `int = Path()`.
+
+```python
+# FastAPI style
+async def get(item_id: Annotated[int, Path(ge=1)]): ...
+
+# FasterAPI style
+async def get(item_id: int = Path()): ...
+```
+
+### `response_model` is not yet fully supported
-## 5. OpenAPI and docs
+FasterAPI uses the return type annotation for response serialisation. If you relied
+on `response_model=` for filtering fields, use a separate response struct:
-OpenAPI generation is supported; field metadata may differ slightly from Pydantic’s schema
-extras. Regenerate your client or inspect `/openapi.json` after switching models.
+```python
+# FastAPI
+@app.get("/users/{id}", response_model=UserPublic)
+async def get_user(id: int) -> UserFull: ...
+
+# FasterAPI — return the right type directly
+@app.get("/users/{id}")
+async def get_user(id: int) -> UserPublic:
+ full = await fetch_user(id)
+ return UserPublic(id=full.id, name=full.name)
+```
-## 6. Testing
+### Starlette-specific imports
-Use **`TestClient`** from **`FasterAPI`** (httpx-based), similar to Starlette’s test client.
-Install **`httpx`** in the environment where you run tests (`pip install httpx` — it is included in
-the project’s **dev** extras but not in the minimal runtime install). Update imports and rerun your
-suite; fix any tests that imported Starlette types directly.
+Any code that imported directly from `starlette.*` needs updating to equivalent
+FasterAPI imports or plain Python/httpx alternatives.
-## Suggested order of work
+## Suggested migration order
-1. Add **FasterAPI** beside FastAPI in a branch; swap the app factory and deps.
+1. Add **FasterAPI** beside FastAPI in a branch; swap the app factory.
2. Convert **models** to `msgspec.Struct` and fix type errors.
3. Run **tests** and fix request/response assumptions.
4. Measure **latency/throughput** in staging if performance is a goal.
+5. Remove FastAPI / Pydantic / Starlette from dependencies.
+
+## Real-world case studies
+
+### Simple CRUD service
+
+A typical CRUD API with 10–20 routes, PostgreSQL backend, and simple token auth
+typically migrates in **2–4 hours**:
+
+- 30 min: swap imports and app constructor
+- 1–2 hours: convert Pydantic models to msgspec structs
+- 30 min: fix test imports and re-run suite
+- 30 min: verify OpenAPI output and update generated client
+
+### Performance gains observed
+
+On equivalent hardware, teams have reported:
+
+- **2–3×** improvement in JSON serialisation throughput (msgspec vs Pydantic)
+- **10–20%** lower p99 latency under load (radix router + uvloop)
+- Smaller memory footprint (no Pydantic overhead per request)
If something you need is missing, open an issue with a minimal reproduction against
the [GitHub repo](https://github.com/FasterApiWeb/FasterAPI).
diff --git a/docs/security/http-basic-auth.md b/docs/security/http-basic-auth.md
new file mode 100644
index 0000000..3e2e588
--- /dev/null
+++ b/docs/security/http-basic-auth.md
@@ -0,0 +1,164 @@
+# HTTP Basic Authentication
+
+HTTP Basic Auth sends a `username:password` pair encoded in Base64 with every
+request. It is the simplest authentication scheme — suitable for internal tools,
+admin panels, or services protected by TLS where token management would be overkill.
+
+!!! warning
+ **Always use HTTPS** with Basic Auth. The credentials are only Base64-encoded, not
+ encrypted, so they are readable in plain text over HTTP.
+
+## How it works
+
+The client sends:
+
+```
+Authorization: Basic
+```
+
+The server decodes and validates the credentials. If invalid, it responds with:
+
+```
+HTTP/1.1 401 Unauthorized
+WWW-Authenticate: Basic realm="Protected"
+```
+
+## Implementation
+
+```python
+import base64
+import secrets
+from FasterAPI import Faster, Header, HTTPException
+
+app = Faster()
+
+# Store hashed passwords in production!
+USERS = {
+ "admin": "super-secret",
+ "reader": "read-only-pass",
+}
+
+
+def decode_basic_auth(authorization: str | None) -> tuple[str, str] | None:
+ if not authorization or not authorization.startswith("Basic "):
+ return None
+ try:
+ decoded = base64.b64decode(authorization.removeprefix("Basic ")).decode("utf-8")
+ username, _, password = decoded.partition(":")
+ return username, password
+ except Exception:
+ return None
+
+
+async def require_basic_auth(
+ authorization: str | None = Header(default=None),
+) -> str:
+ credentials = decode_basic_auth(authorization)
+ if credentials is None:
+ raise HTTPException(
+ status_code=401,
+ detail="Not authenticated",
+ headers={"WWW-Authenticate": 'Basic realm="Protected Area"'},
+ )
+
+ username, password = credentials
+ stored = USERS.get(username)
+
+ # Use constant-time comparison to prevent timing attacks
+ if stored is None or not secrets.compare_digest(password, stored):
+ raise HTTPException(
+ status_code=401,
+ detail="Invalid credentials",
+ headers={"WWW-Authenticate": 'Basic realm="Protected Area"'},
+ )
+
+ return username
+```
+
+## Protecting routes
+
+```python
+from FasterAPI import Depends
+
+
+@app.get("/admin", tags=["admin"])
+async def admin_panel(user: str = Depends(require_basic_auth)):
+ return {"logged_in_as": user}
+
+
+@app.get("/reports", tags=["reports"])
+async def reports(user: str = Depends(require_basic_auth)):
+ return {"user": user, "reports": []}
+```
+
+## Testing
+
+```bash
+curl -u admin:super-secret http://localhost:8000/admin
+# {"logged_in_as":"admin"}
+
+curl http://localhost:8000/admin
+# 401 Unauthorized
+```
+
+With Python requests:
+
+```python
+import httpx
+
+r = httpx.get("http://localhost:8000/admin", auth=("admin", "super-secret"))
+print(r.json())
+```
+
+## Hashing passwords
+
+Do **not** store plain-text passwords. Use bcrypt:
+
+```bash
+pip install passlib[bcrypt]
+```
+
+```python
+from passlib.context import CryptContext
+
+pwd_context = CryptContext(schemes=["bcrypt"])
+
+HASHED_USERS = {
+ "admin": pwd_context.hash("super-secret"),
+}
+
+
+def verify(username: str, password: str) -> bool:
+ hashed = HASHED_USERS.get(username)
+ if hashed is None:
+ return False
+ return pwd_context.verify(password, hashed)
+```
+
+Update the dependency:
+
+```python
+async def require_basic_auth(authorization: str | None = Header(default=None)) -> str:
+ creds = decode_basic_auth(authorization)
+ if creds is None or not verify(*creds):
+ raise HTTPException(
+ 401,
+ headers={"WWW-Authenticate": 'Basic realm="Protected"'},
+ )
+ return creds[0]
+```
+
+## When to use Basic Auth
+
+| Suitable | Not suitable |
+|---|---|
+| Internal admin panels over HTTPS | Public-facing APIs |
+| Simple CLI tools | Apps requiring token refresh |
+| Quick prototypes | Multi-tenant SaaS |
+
+For public APIs, prefer [OAuth2 + JWT](oauth2-jwt.md).
+
+## Next steps
+
+- [OAuth2 with Password + JWT](oauth2-jwt.md) — stateless token authentication.
+- [OAuth2 Scopes](oauth2-scopes.md) — fine-grained permissions.
diff --git a/docs/security/index.md b/docs/security/index.md
new file mode 100644
index 0000000..f44cd0b
--- /dev/null
+++ b/docs/security/index.md
@@ -0,0 +1,55 @@
+# Security
+
+Security is an important — and often underestimated — part of API development.
+This section covers authentication and authorisation patterns for FasterAPI.
+
+## Pages
+
+| Topic | What you learn |
+|---|---|
+| [OAuth2 with Password + JWT](oauth2-jwt.md) | Bearer tokens, login endpoint, protected routes |
+| [OAuth2 Scopes](oauth2-scopes.md) | Fine-grained permission checks |
+| [HTTP Basic Auth](http-basic-auth.md) | Simple username / password authentication |
+
+## Core concepts
+
+### Authentication vs authorisation
+
+- **Authentication** — verifying *who* a user is (login, token validation).
+- **Authorisation** — verifying *what* a user may do (role checks, scopes).
+
+### Secrets and keys
+
+- Store secrets in **environment variables**, never in code or version control.
+- Use a cryptographically secure random value for `SECRET_KEY`:
+
+```bash
+python -c "import secrets; print(secrets.token_hex(32))"
+```
+
+### HTTPS
+
+Always serve your API over **HTTPS** in production so credentials are not transmitted
+in plain text. See [Behind a Proxy](../advanced/behind-proxy.md) and
+[Deployment](../deployment/index.md) for setup guidance.
+
+### Dependency-based security
+
+FasterAPI's `Depends()` system is well-suited for security: declare an auth
+dependency once and reuse it across many routes.
+
+```python
+from FasterAPI import Depends, HTTPException, Header
+
+
+async def require_auth(authorization: str | None = Header(default=None)):
+ if not authorization:
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ # validate token here
+ return parse_token(authorization)
+```
+
+## Next steps
+
+- Start with [OAuth2 with Password + JWT](oauth2-jwt.md) for the most common pattern.
+- For simple internal tools, [HTTP Basic Auth](http-basic-auth.md) may be sufficient.
diff --git a/docs/security/oauth2-jwt.md b/docs/security/oauth2-jwt.md
new file mode 100644
index 0000000..241c6aa
--- /dev/null
+++ b/docs/security/oauth2-jwt.md
@@ -0,0 +1,186 @@
+# OAuth2 with Password Flow & JWT Tokens
+
+This guide implements a complete login-and-token authentication system using the
+**OAuth2 Password flow** and **JSON Web Tokens (JWT)**.
+
+## Install dependencies
+
+```bash
+pip install python-jose[cryptography] passlib[bcrypt]
+```
+
+- `python-jose` — JWT encode/decode
+- `passlib` — password hashing (bcrypt)
+
+## Configuration
+
+```python
+# auth.py
+import os
+from datetime import datetime, timedelta, timezone
+from jose import JWTError, jwt
+from passlib.context import CryptContext
+
+SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-in-production")
+ALGORITHM = "HS256"
+ACCESS_TOKEN_EXPIRE_MINUTES = 30
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+def hash_password(password: str) -> str:
+ return pwd_context.hash(password)
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ return pwd_context.verify(plain, hashed)
+
+
+def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
+ to_encode = data.copy()
+ expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
+ to_encode["exp"] = expire
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+
+
+def decode_access_token(token: str) -> dict | None:
+ try:
+ return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+ except JWTError:
+ return None
+```
+
+## User models
+
+```python
+import msgspec
+
+
+class UserInDB(msgspec.Struct):
+ username: str
+ email: str
+ hashed_password: str
+ disabled: bool = False
+
+
+class User(msgspec.Struct):
+ username: str
+ email: str
+ disabled: bool = False
+
+
+class TokenResponse(msgspec.Struct):
+ access_token: str
+ token_type: str = "bearer"
+```
+
+## Fake user database (replace with real DB)
+
+```python
+from auth import hash_password
+
+_fake_users_db: dict[str, UserInDB] = {
+ "alice": UserInDB(
+ username="alice",
+ email="alice@example.com",
+ hashed_password=hash_password("secret"),
+ )
+}
+```
+
+## Auth dependency
+
+```python
+from FasterAPI import Header, HTTPException, Depends
+from auth import decode_access_token
+
+
+async def get_current_user(
+ authorization: str | None = Header(default=None),
+) -> User:
+ credentials_exception = HTTPException(
+ status_code=401,
+ detail="Could not validate credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ if not authorization or not authorization.startswith("Bearer "):
+ raise credentials_exception
+
+ token = authorization.removeprefix("Bearer ")
+ payload = decode_access_token(token)
+ if payload is None:
+ raise credentials_exception
+
+ username: str | None = payload.get("sub")
+ if username is None:
+ raise credentials_exception
+
+ user_data = _fake_users_db.get(username)
+ if user_data is None or user_data.disabled:
+ raise credentials_exception
+
+ return User(username=user_data.username, email=user_data.email)
+```
+
+## Login endpoint
+
+```python
+from FasterAPI import Faster, Form
+from datetime import timedelta
+from auth import verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
+
+app = Faster()
+
+
+@app.post("/auth/token", tags=["auth"])
+async def login(username: str = Form(), password: str = Form()) -> TokenResponse:
+ user = _fake_users_db.get(username)
+ if not user or not verify_password(password, user.hashed_password):
+ raise HTTPException(
+ status_code=401,
+ detail="Incorrect username or password",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ token = create_access_token(
+ {"sub": user.username},
+ expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+ )
+ return TokenResponse(access_token=token)
+```
+
+## Protected routes
+
+```python
+@app.get("/users/me", tags=["users"])
+async def read_me(current_user: User = Depends(get_current_user)) -> User:
+ return current_user
+
+
+@app.get("/items", tags=["items"])
+async def list_items(current_user: User = Depends(get_current_user)):
+ return [{"name": "Widget", "owner": current_user.username}]
+```
+
+## Try it out
+
+```bash
+# Get a token
+curl -X POST http://localhost:8000/auth/token \
+ -d "username=alice&password=secret"
+# {"access_token":"eyJ...","token_type":"bearer"}
+
+# Use the token
+curl http://localhost:8000/users/me \
+ -H "Authorization: Bearer eyJ..."
+```
+
+## Token refresh
+
+For long-lived sessions, issue a **refresh token** alongside the access token and
+provide a `/auth/refresh` endpoint. Store refresh tokens in an httpOnly cookie and
+short-lived access tokens in memory.
+
+## Next steps
+
+- [OAuth2 Scopes](oauth2-scopes.md) — role-based / permission-based access.
+- [HTTP Basic Auth](http-basic-auth.md) — simpler alternative for internal tools.
diff --git a/docs/security/oauth2-scopes.md b/docs/security/oauth2-scopes.md
new file mode 100644
index 0000000..9fd5113
--- /dev/null
+++ b/docs/security/oauth2-scopes.md
@@ -0,0 +1,138 @@
+# OAuth2 Scopes
+
+Scopes provide **fine-grained authorisation** — a token may grant access to only
+a subset of operations (e.g. `read:items` but not `write:items`).
+
+## Defining scopes
+
+```python
+# scopes.py
+SCOPES = {
+ "read:items": "Read access to items",
+ "write:items": "Create and update items",
+ "delete:items":"Delete items",
+ "admin": "Full administrative access",
+}
+```
+
+## Encoding scopes in the token
+
+Add a `scopes` claim when creating the JWT:
+
+```python
+from auth import create_access_token
+from datetime import timedelta
+
+token = create_access_token(
+ {"sub": "alice", "scopes": ["read:items", "write:items"]},
+ expires_delta=timedelta(minutes=30),
+)
+```
+
+## Security dependency with scope check
+
+```python
+from FasterAPI import Header, HTTPException, Depends
+from auth import decode_access_token
+
+
+def require_scope(*required_scopes: str):
+ """Factory that returns a dependency checking for the given scopes."""
+
+ async def _check(authorization: str | None = Header(default=None)):
+ exc = HTTPException(
+ status_code=401,
+ detail="Not authenticated",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ if not authorization or not authorization.startswith("Bearer "):
+ raise exc
+
+ payload = decode_access_token(authorization.removeprefix("Bearer "))
+ if payload is None:
+ raise exc
+
+ token_scopes: list[str] = payload.get("scopes", [])
+ for scope in required_scopes:
+ if scope not in token_scopes:
+ raise HTTPException(
+ status_code=403,
+ detail=f"Insufficient scope. Required: {scope}",
+ headers={"WWW-Authenticate": f'Bearer scope="{scope}"'},
+ )
+ return payload
+
+ return _check
+```
+
+## Using scope dependencies in routes
+
+```python
+from FasterAPI import Faster, Depends
+from scopes import require_scope
+
+app = Faster()
+
+
+@app.get("/items", tags=["items"])
+async def list_items(_: dict = Depends(require_scope("read:items"))):
+ return [{"name": "Widget"}]
+
+
+@app.post("/items", status_code=201, tags=["items"])
+async def create_item(_: dict = Depends(require_scope("write:items"))):
+ return {"created": True}
+
+
+@app.delete("/items/{item_id}", status_code=204, tags=["items"])
+async def delete_item(item_id: int, _: dict = Depends(require_scope("delete:items"))):
+ pass
+
+
+@app.get("/admin", tags=["admin"])
+async def admin_panel(_: dict = Depends(require_scope("admin"))):
+ return {"panel": "admin"}
+```
+
+## Scoped login endpoint
+
+Return different scopes based on the user's role:
+
+```python
+ROLE_SCOPES = {
+ "user": ["read:items"],
+ "editor": ["read:items", "write:items"],
+ "admin": ["read:items", "write:items", "delete:items", "admin"],
+}
+
+@app.post("/auth/token")
+async def login(username: str = Form(), password: str = Form()):
+ user = _fake_users_db.get(username)
+ if not user or not verify_password(password, user.hashed_password):
+ raise HTTPException(401, "Incorrect credentials")
+
+ role = getattr(user, "role", "user")
+ scopes = ROLE_SCOPES.get(role, [])
+
+ token = create_access_token(
+ {"sub": user.username, "scopes": scopes},
+ )
+ return {"access_token": token, "token_type": "bearer", "scopes": scopes}
+```
+
+## OAuth2 scope request (standard flow)
+
+In a standard OAuth2 flow the client requests specific scopes at login time:
+
+```bash
+curl -X POST /auth/token \
+ -d "username=alice&password=secret&scope=read:items write:items"
+```
+
+The server issues a token containing only the intersection of the requested scopes
+and the user's allowed scopes.
+
+## Next steps
+
+- [OAuth2 with Password + JWT](oauth2-jwt.md) — the base authentication layer.
+- [HTTP Basic Auth](http-basic-auth.md) — simpler alternative.
diff --git a/docs/tutorial/background-tasks.md b/docs/tutorial/background-tasks.md
new file mode 100644
index 0000000..9acda05
--- /dev/null
+++ b/docs/tutorial/background-tasks.md
@@ -0,0 +1,103 @@
+# Background Tasks
+
+Background tasks run **after the response is sent** to the client — useful for
+sending emails, writing audit logs, or triggering slow processes without blocking
+the HTTP response.
+
+## Injecting `BackgroundTasks`
+
+Declare a `BackgroundTasks` parameter (the type annotation is the trigger):
+
+```python
+from FasterAPI import Faster, BackgroundTasks
+
+app = Faster()
+
+
+def write_log(message: str) -> None:
+ with open("log.txt", "a") as f:
+ f.write(message + "\n")
+
+
+@app.post("/send-notification")
+async def send_notification(email: str, tasks: BackgroundTasks):
+ tasks.add_task(write_log, f"notification sent to {email}")
+ return {"message": "Notification queued"}
+```
+
+The response is returned immediately; `write_log` runs afterwards.
+
+## Async background tasks
+
+```python
+import asyncio
+
+
+async def send_email(to: str, subject: str) -> None:
+ await asyncio.sleep(0.5) # simulate async I/O
+ print(f"Email sent to {to}: {subject}")
+
+
+@app.post("/register")
+async def register(email: str, tasks: BackgroundTasks):
+ tasks.add_task(send_email, email, "Welcome to FasterAPI!")
+ return {"registered": email}
+```
+
+## Multiple tasks
+
+```python
+@app.post("/order")
+async def place_order(order_id: int, tasks: BackgroundTasks):
+ tasks.add_task(write_log, f"order {order_id} placed")
+ tasks.add_task(send_email, "warehouse@example.com", f"New order {order_id}")
+ return {"order_id": order_id, "status": "placed"}
+```
+
+Tasks execute in the order they were added.
+
+## Background tasks in dependencies
+
+```python
+from FasterAPI import Depends
+
+
+async def audit_log(tasks: BackgroundTasks, action: str):
+ tasks.add_task(write_log, action)
+
+
+@app.delete("/items/{item_id}")
+async def delete_item(
+ item_id: int,
+ tasks: BackgroundTasks,
+):
+ await audit_log(tasks, f"deleted item {item_id}")
+ return {"deleted": item_id}
+```
+
+## Error handling
+
+If a background task raises an exception, it is **not** propagated to the client
+(the response has already been sent). Log exceptions inside the task:
+
+```python
+async def safe_task():
+ try:
+ await do_risky_work()
+ except Exception as exc:
+ print(f"Background task failed: {exc}")
+```
+
+## When to use background tasks vs sub-interpreters
+
+| Scenario | Recommended approach |
+|---|---|
+| I/O-bound work (email, DB write) | `BackgroundTasks` |
+| CPU-bound work (image processing) | `SubInterpreterPool` / `run_in_subinterpreter` |
+
+See [Concurrency & Parallelism](../concepts/concurrency.md) for details.
+
+## Next steps
+
+- [Middleware](middleware.md) — apply logic to every request.
+- [Dependencies](dependencies.md) — inject `BackgroundTasks` via `Depends()`.
diff --git a/docs/tutorial/dependencies.md b/docs/tutorial/dependencies.md
new file mode 100644
index 0000000..068f994
--- /dev/null
+++ b/docs/tutorial/dependencies.md
@@ -0,0 +1,135 @@
+# Dependencies
+
+The `Depends()` system lets you declare **reusable, injectable logic** — authentication,
+database sessions, pagination, etc. — that FasterAPI resolves before calling your
+route handler.
+
+## Basic dependency
+
+Any callable (function or class) can be a dependency:
+
+```python
+from FasterAPI import Faster, Depends, Query
+
+app = Faster()
+
+
+async def common_pagination(skip: int = Query(default=0), limit: int = Query(default=10)):
+ return {"skip": skip, "limit": limit}
+
+
+@app.get("/items")
+async def list_items(pagination: dict = Depends(common_pagination)):
+ return pagination
+```
+
+```bash
+curl "http://localhost:8000/items?skip=5&limit=3"
+# {"skip":5,"limit":3}
+```
+
+FasterAPI resolves `common_pagination` first, then passes the result as `pagination`.
+
+## Dependency parameters
+
+Dependencies declare their own parameters exactly like route handlers — path, query,
+header, cookie, body, or other dependencies:
+
+```python
+from FasterAPI import Header, HTTPException
+
+
+async def verify_token(x_token: str = Header()):
+ if x_token != "secret":
+ raise HTTPException(status_code=403, detail="Invalid token")
+ return x_token
+
+
+@app.get("/protected")
+async def protected_route(token: str = Depends(verify_token)):
+ return {"token": token}
+```
+
+## Chained dependencies
+
+```python
+async def get_db():
+ # imagine opening a DB connection
+ return {"connected": True}
+
+
+async def get_current_user(db: dict = Depends(get_db)):
+ return {"user": "alice", "db": db}
+
+
+@app.get("/me")
+async def read_me(user: dict = Depends(get_current_user)):
+ return user
+```
+
+## Class-based dependencies
+
+Use `__call__` so the class instance is the callable:
+
+```python
+class RateLimiter:
+ def __init__(self, max_calls: int) -> None:
+ self.max_calls = max_calls
+ self._counts: dict[str, int] = {}
+
+ async def __call__(self, request: Request):
+ ip = request.client[0] if request.client else "unknown"
+ self._counts[ip] = self._counts.get(ip, 0) + 1
+ if self._counts[ip] > self.max_calls:
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
+
+
+limiter = RateLimiter(max_calls=100)
+
+
+@app.get("/api/data")
+async def get_data(_: None = Depends(limiter)):
+ return {"data": "ok"}
+```
+
+## Dependency caching
+
+By default, a dependency is **called once per request** even if multiple handlers
+or sub-dependencies declare it. Disable with `use_cache=False`:
+
+```python
+Depends(get_db, use_cache=False)
+```
+
+## Dependencies without return value
+
+Use `None` as the return type for side-effect-only dependencies (auth checks,
+rate limiting):
+
+```python
+async def require_admin(token: str = Header(alias="x-admin-token")):
+ if token != "admin-secret":
+ raise HTTPException(status_code=403)
+
+
+@app.delete("/users/{user_id}")
+async def delete_user(user_id: int, _: None = Depends(require_admin)):
+ return {"deleted": user_id}
+```
+
+## Multiple dependencies
+
+```python
+@app.get("/dashboard")
+async def dashboard(
+ user: dict = Depends(get_current_user),
+ _: None = Depends(require_admin),
+):
+ return {"user": user}
+```
+
+## Next steps
+
+- [Background Tasks](background-tasks.md) — defer work until after the response.
+- [Advanced: Testing with Overrides](../advanced/testing-overrides.md) — replace
+ dependencies in tests.
diff --git a/docs/tutorial/error-handling.md b/docs/tutorial/error-handling.md
new file mode 100644
index 0000000..8fe97af
--- /dev/null
+++ b/docs/tutorial/error-handling.md
@@ -0,0 +1,132 @@
+# Error Handling
+
+FasterAPI provides two built-in exception types and lets you register custom handlers
+for any exception class.
+
+## HTTPException
+
+Raise `HTTPException` anywhere in a handler to return an HTTP error response:
+
+```python
+from FasterAPI import Faster, HTTPException
+
+app = Faster()
+
+items = {"foo": "The Foo item"}
+
+
+@app.get("/items/{item_id}")
+async def get_item(item_id: str):
+ if item_id not in items:
+ raise HTTPException(status_code=404, detail="Item not found")
+ return {"item": items[item_id]}
+```
+
+```bash
+curl http://localhost:8000/items/bar
+# HTTP 404
+# {"detail":"Item not found"}
+```
+
+You can pass any JSON-serialisable value as `detail`:
+
+```python
+raise HTTPException(
+ status_code=422,
+ detail={"field": "price", "msg": "must be positive"},
+)
+```
+
+## Custom response headers
+
+```python
+raise HTTPException(
+ status_code=401,
+ detail="Not authenticated",
+ headers={"WWW-Authenticate": "Bearer"},
+)
+```
+
+## RequestValidationError
+
+Raised automatically when request data fails validation (e.g. wrong type, missing
+required field). The default handler returns a **422** with a structured error body:
+
+```json
+{
+ "detail": [
+ {
+ "loc": ["body"],
+ "msg": "Expected `int`, got `str` - at `$.item_id`",
+ "type": "value_error.msgspec"
+ }
+ ]
+}
+```
+
+## Custom exception handlers
+
+Register a handler for any exception class via `add_exception_handler`:
+
+```python
+from FasterAPI import Faster, Request, HTTPException
+from FasterAPI.response import JSONResponse
+
+app = Faster()
+
+
+class ItemNotFoundError(Exception):
+ def __init__(self, item_id: int) -> None:
+ self.item_id = item_id
+
+
+def item_not_found_handler(request: Request, exc: ItemNotFoundError):
+ return JSONResponse(
+ {"detail": f"Item {exc.item_id} does not exist"},
+ status_code=404,
+ )
+
+
+app.add_exception_handler(ItemNotFoundError, item_not_found_handler)
+
+
+@app.get("/items/{item_id}")
+async def get_item(item_id: int):
+ raise ItemNotFoundError(item_id)
+```
+
+Async handlers are also supported:
+
+```python
+async def async_handler(request: Request, exc: ValueError):
+ return JSONResponse({"error": str(exc)}, status_code=400)
+
+app.add_exception_handler(ValueError, async_handler)
+```
+
+## Override built-in handlers
+
+Override validation or HTTP exception behaviour the same way:
+
+```python
+from FasterAPI.exceptions import RequestValidationError
+
+
+async def custom_validation_handler(request: Request, exc: RequestValidationError):
+ return JSONResponse(
+ {"errors": exc.errors, "hint": "Check your request body"},
+ status_code=422,
+ )
+
+app.add_exception_handler(RequestValidationError, custom_validation_handler)
+```
+
+## Exception handler precedence
+
+Handlers are matched against `type(exc).__mro__`, so a handler for a base class
+catches subclass exceptions when no more-specific handler is registered.
+
+## Next steps
+
+- [Dependencies](dependencies.md) — reuse auth/validation logic.
+- [Middleware](middleware.md) — intercept all requests including error paths.
diff --git a/docs/tutorial/form-and-files.md b/docs/tutorial/form-and-files.md
new file mode 100644
index 0000000..1071a89
--- /dev/null
+++ b/docs/tutorial/form-and-files.md
@@ -0,0 +1,100 @@
+# Form Data & File Uploads
+
+HTML forms and file uploads use `multipart/form-data` or
+`application/x-www-form-urlencoded` — not JSON. FasterAPI handles both with the
+`Form()`, `File()`, and `UploadFile` helpers.
+
+## Form fields
+
+```python
+from FasterAPI import Faster, Form
+
+app = Faster()
+
+
+@app.post("/login")
+async def login(username: str = Form(), password: str = Form()):
+ return {"username": username}
+```
+
+```bash
+curl -X POST http://localhost:8000/login \
+ -d "username=alice&password=secret"
+```
+
+!!! note
+ You cannot mix `Form()` fields and a JSON body (`msgspec.Struct`) in the same
+ endpoint — HTTP only allows one body encoding per request.
+
+## Uploading a single file
+
+```python
+from FasterAPI import Faster, UploadFile, File
+
+app = Faster()
+
+
+@app.post("/upload")
+async def upload_file(file: UploadFile = File()):
+ contents = await file.read()
+ return {"filename": file.filename, "size": len(contents)}
+```
+
+`UploadFile` exposes:
+
+| Attribute / method | Description |
+|--------------------|-------------|
+| `filename` | Original filename from the client |
+| `content_type` | MIME type (e.g. `image/png`) |
+| `await file.read()` | Read all bytes |
+| `await file.read(n)` | Read up to *n* bytes |
+
+## Multiple files
+
+```python
+@app.post("/multi-upload")
+async def multi_upload(files: list[UploadFile] = File()):
+ return [{"filename": f.filename} for f in files]
+```
+
+## Mixed form and file
+
+Combine `Form()` and `File()` fields freely:
+
+```python
+@app.post("/profile")
+async def update_profile(
+ bio: str = Form(),
+ avatar: UploadFile = File(),
+):
+ data = await avatar.read()
+ return {"bio": bio, "avatar_size": len(data)}
+```
+
+## Form with optional file
+
+```python
+@app.post("/create-post")
+async def create_post(
+ title: str = Form(),
+ image: UploadFile | None = File(default=None),
+):
+ return {"title": title, "has_image": image is not None}
+```
+
+## Accessing raw form data
+
+```python
+from FasterAPI import Request
+
+
+@app.post("/raw-form")
+async def raw_form(request: Request):
+ form = await request.form()
+ return dict(form)
+```
+
+## Next steps
+
+- [Error Handling](error-handling.md) — what happens when validation fails.
+- [Dependencies](dependencies.md) — reuse form parsing across routes.
diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md
new file mode 100644
index 0000000..fcb555e
--- /dev/null
+++ b/docs/tutorial/index.md
@@ -0,0 +1,38 @@
+# Tutorial — User Guide
+
+This tutorial walks you through FasterAPI **step by step**. Each section builds on the
+previous one, so follow them in order if you are new to the framework.
+
+By the end you will know how to:
+
+- declare path, query, header, cookie, and body parameters
+- validate and serialize data with **msgspec**
+- compose dependencies with `Depends()`
+- run background tasks
+- handle errors gracefully
+- document your API automatically via OpenAPI / Swagger UI
+
+## Prerequisites
+
+- Python 3.10 or later (3.13 recommended)
+- Basic familiarity with `async`/`await`
+- FasterAPI installed (`pip install faster-api-web`)
+
+## Pages
+
+| Topic | What you learn |
+|---|---|
+| [Path Parameters](path-parameters.md) | Dynamic path segments, type coercion |
+| [Query Parameters](query-parameters.md) | Optional/required query strings, aliases |
+| [Request Body](request-body.md) | Typed JSON bodies with `msgspec.Struct` |
+| [Response Model](response-model.md) | Return types, status codes, response filtering |
+| [Form Data & File Uploads](form-and-files.md) | `Form()`, `File()`, `UploadFile` |
+| [Error Handling](error-handling.md) | `HTTPException`, custom exception handlers |
+| [Dependencies](dependencies.md) | `Depends()`, scoped DI, chained deps |
+| [Background Tasks](background-tasks.md) | Fire-and-forget after the response |
+| [Middleware](middleware.md) | CORS, GZip, custom middleware |
+| [WebSockets](websockets.md) | Real-time bidirectional connections |
+| [Metadata & Docs](metadata.md) | Tags, summaries, OpenAPI customisation |
+
+> **Already a FastAPI user?** See [Migrating from FastAPI](../migration-from-fastapi.md)
+> for a focused diff rather than a full walkthrough.
diff --git a/docs/tutorial/metadata.md b/docs/tutorial/metadata.md
new file mode 100644
index 0000000..a55ad0f
--- /dev/null
+++ b/docs/tutorial/metadata.md
@@ -0,0 +1,124 @@
+# Metadata & Docs
+
+FasterAPI auto-generates an OpenAPI schema and serves Swagger UI and ReDoc. You can
+customise every aspect — app-level metadata, per-route tags, summaries, and more.
+
+## Application metadata
+
+```python
+from FasterAPI import Faster
+
+app = Faster(
+ title="My Inventory API",
+ description="Manage items, orders, and users.",
+ version="2.1.0",
+ docs_url="/docs", # Swagger UI (default)
+ redoc_url="/redoc", # ReDoc (default)
+ openapi_url="/openapi.json",
+)
+```
+
+Visit `http://localhost:8000/docs` for the interactive Swagger UI.
+
+## Disable docs
+
+Set `docs_url=None` and / or `redoc_url=None`:
+
+```python
+app = Faster(docs_url=None, redoc_url=None)
+```
+
+To disable OpenAPI entirely (no schema endpoint):
+
+```python
+app = Faster(openapi_url=None)
+```
+
+## Route tags
+
+Group routes in the Swagger UI sidebar with `tags`:
+
+```python
+@app.get("/items", tags=["items"])
+async def list_items():
+ return []
+
+
+@app.post("/items", tags=["items"])
+async def create_item():
+ return {}
+
+
+@app.get("/users", tags=["users"])
+async def list_users():
+ return []
+```
+
+## Summary and description
+
+```python
+@app.get(
+ "/items/{item_id}",
+ summary="Retrieve a single item",
+ tags=["items"],
+)
+async def get_item(item_id: int):
+ """Return the item identified by *item_id*.
+
+ Raises 404 if the item does not exist.
+ """
+ return {"item_id": item_id}
+```
+
+The docstring appears as the route's **description** in the OpenAPI schema.
+
+## Deprecated routes
+
+Mark old endpoints without removing them:
+
+```python
+@app.get("/old-endpoint", deprecated=True)
+async def old_endpoint():
+ return {"moved": "/new-endpoint"}
+```
+
+## Custom response status code
+
+```python
+@app.post("/items", status_code=201, tags=["items"])
+async def create_item():
+ return {}
+```
+
+## Using routers for organisation
+
+Group related routes in a `FasterRouter`:
+
+```python
+from FasterAPI import Faster, FasterRouter
+
+app = Faster()
+router = FasterRouter()
+
+
+@router.get("/", tags=["items"])
+async def list_items():
+ return []
+
+
+@router.post("/", tags=["items"], status_code=201)
+async def create_item():
+ return {}
+
+
+app.include_router(router, prefix="/items")
+```
+
+All routes from the router are mounted under `/items` and inherit the provided tags.
+
+## Next steps
+
+- [Advanced: OpenAPI Customisation](../advanced/openapi-customization.md) — extend or
+ conditionally show the schema.
+- [Advanced: Bigger Applications](../advanced/bigger-apps.md) — split routes across
+ multiple files.
diff --git a/docs/tutorial/middleware.md b/docs/tutorial/middleware.md
new file mode 100644
index 0000000..d18b24c
--- /dev/null
+++ b/docs/tutorial/middleware.md
@@ -0,0 +1,124 @@
+# Middleware
+
+Middleware wraps every request and response. Use it for cross-cutting concerns:
+CORS, compression, authentication headers, logging, rate-limiting.
+
+## Adding built-in middleware
+
+### CORS
+
+```python
+from FasterAPI import Faster, CORSMiddleware
+
+app = Faster()
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["https://example.com", "https://app.example.com"],
+ allow_methods=["GET", "POST", "PUT", "DELETE"],
+ allow_headers=["Authorization", "Content-Type"],
+ allow_credentials=True,
+)
+```
+
+Allow all origins during development:
+
+```python
+app.add_middleware(CORSMiddleware, allow_origins=["*"])
+```
+
+### GZip compression
+
+```python
+from FasterAPI import GZipMiddleware
+
+app.add_middleware(GZipMiddleware, minimum_size=1000)
+```
+
+Responses smaller than `minimum_size` bytes are sent uncompressed.
+
+### HTTPS redirect
+
+```python
+from FasterAPI import HTTPSRedirectMiddleware
+
+app.add_middleware(HTTPSRedirectMiddleware)
+```
+
+Redirects every HTTP request to its HTTPS equivalent (301).
+
+### Trusted host
+
+```python
+from FasterAPI import TrustedHostMiddleware
+
+app.add_middleware(
+ TrustedHostMiddleware,
+ allowed_hosts=["example.com", "*.example.com"],
+)
+```
+
+Requests with a `Host` header not in the list receive a 400 response.
+
+## Custom middleware
+
+Subclass `BaseHTTPMiddleware` and override `dispatch`:
+
+```python
+import time
+from FasterAPI import Faster, BaseHTTPMiddleware, Request
+from typing import Any
+
+app = Faster()
+
+
+class TimingMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, scope, receive, send):
+ start = time.perf_counter()
+
+ captured: list[dict] = []
+
+ async def capture_send(message: dict) -> None:
+ captured.append(message)
+
+ await self.app(scope, receive, capture_send)
+
+ elapsed = time.perf_counter() - start
+ for message in captured:
+ if message["type"] == "http.response.start":
+ headers = list(message.get("headers", []))
+ headers.append(
+ (b"x-process-time", f"{elapsed:.4f}".encode())
+ )
+ message = {**message, "headers": headers}
+ await send(message)
+
+
+app.add_middleware(TimingMiddleware)
+```
+
+## Middleware execution order
+
+Middleware is applied in **reverse registration order** — the last added wraps
+outermost. For example:
+
+```python
+app.add_middleware(CORSMiddleware, ...) # runs second (outer)
+app.add_middleware(GZipMiddleware, ...) # runs first (inner)
+```
+
+## Accessing request information in middleware
+
+```python
+class LoggingMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, scope, receive, send):
+ method = scope.get("method", "")
+ path = scope.get("path", "")
+ print(f"→ {method} {path}")
+ await self.app(scope, receive, send)
+```
+
+## Next steps
+
+- [WebSockets](websockets.md) — real-time bidirectional communication.
+- [Metadata & Docs](metadata.md) — customise the OpenAPI UI.
diff --git a/docs/tutorial/path-parameters.md b/docs/tutorial/path-parameters.md
new file mode 100644
index 0000000..2ae18da
--- /dev/null
+++ b/docs/tutorial/path-parameters.md
@@ -0,0 +1,82 @@
+# Path Parameters
+
+Path parameters are **variable segments** inside the URL, written as `{name}` in the
+route pattern.
+
+## Basic path parameter
+
+```python
+from FasterAPI import Faster
+
+app = Faster()
+
+
+@app.get("/items/{item_id}")
+async def read_item(item_id: str):
+ return {"item_id": item_id}
+```
+
+```bash
+curl http://localhost:8000/items/foo
+# {"item_id":"foo"}
+```
+
+## Type coercion
+
+Annotate the parameter with a Python type and FasterAPI will coerce the string from
+the URL automatically. If the value cannot be converted, a **422** error is returned.
+
+```python
+@app.get("/items/{item_id}")
+async def read_item(item_id: int):
+ return {"item_id": item_id}
+```
+
+```bash
+curl http://localhost:8000/items/42 # {"item_id":42}
+curl http://localhost:8000/items/abc # 422 Unprocessable Entity
+```
+
+## Using `Path()` for extra validation
+
+Import `Path` from `FasterAPI` to attach metadata (title, description) that appears
+in the generated OpenAPI schema:
+
+```python
+from FasterAPI import Faster, Path
+
+app = Faster()
+
+
+@app.get("/users/{user_id}")
+async def get_user(user_id: int = Path(description="The numeric ID of the user")):
+ return {"user_id": user_id}
+```
+
+## Multiple path parameters
+
+```python
+@app.get("/users/{user_id}/items/{item_id}")
+async def get_user_item(user_id: int, item_id: str):
+ return {"user_id": user_id, "item_id": item_id}
+```
+
+## Path parameters and order
+
+Static routes are matched before dynamic ones, so this works as expected:
+
+```python
+@app.get("/users/me") # matched first
+async def read_current_user():
+ return {"user": "the current user"}
+
+
+@app.get("/users/{user_id}") # matched when /users/me does not apply
+async def read_user(user_id: str):
+ return {"user_id": user_id}
+```
+
+## Next steps
+
+- [Query Parameters](query-parameters.md) — optional key-value pairs in the URL.
+- [Request Body](request-body.md) — structured JSON input from the client.
diff --git a/docs/tutorial/query-parameters.md b/docs/tutorial/query-parameters.md
new file mode 100644
index 0000000..fb219e0
--- /dev/null
+++ b/docs/tutorial/query-parameters.md
@@ -0,0 +1,110 @@
+# Query Parameters
+
+Query parameters are the key-value pairs that appear after `?` in a URL:
+
+```
+/items?skip=0&limit=10
+```
+
+Any function parameter that is **not** a path parameter and **not** typed as a
+`msgspec.Struct` is treated as a query parameter.
+
+## Basic query parameters
+
+```python
+from FasterAPI import Faster
+
+app = Faster()
+
+fake_db = [{"name": "alpha"}, {"name": "beta"}, {"name": "gamma"}]
+
+
+@app.get("/items")
+async def list_items(skip: int = 0, limit: int = 10):
+ return fake_db[skip : skip + limit]
+```
+
+```bash
+curl "http://localhost:8000/items?skip=1&limit=2"
+# [{"name":"beta"},{"name":"gamma"}]
+```
+
+Both `skip` and `limit` have **defaults**, so they are optional.
+
+## Required query parameters
+
+Omit the default to make a parameter required:
+
+```python
+@app.get("/search")
+async def search(q: str):
+ return {"query": q}
+```
+
+Calling `/search` without `?q=...` returns a **422** validation error.
+
+## Optional query parameters
+
+Use `str | None` with a default of `None`:
+
+```python
+@app.get("/items/{item_id}")
+async def get_item(item_id: int, detail: str | None = None):
+ result: dict = {"item_id": item_id}
+ if detail:
+ result["detail"] = detail
+ return result
+```
+
+## Boolean query parameters
+
+FasterAPI understands several string representations of booleans:
+`true`, `1`, `on`, `yes` → `True`; `false`, `0`, `off`, `no` → `False`.
+
+```python
+@app.get("/items")
+async def list_items(active: bool = True):
+ return {"active": active}
+```
+
+```bash
+curl "http://localhost:8000/items?active=false"
+# {"active":false}
+```
+
+## Using `Query()` for extra metadata
+
+```python
+from FasterAPI import Faster, Query
+
+app = Faster()
+
+
+@app.get("/items")
+async def list_items(
+ q: str | None = Query(default=None, description="Search term", alias="search"),
+):
+ return {"q": q}
+```
+
+The `alias` means the URL uses `?search=...` while the Python variable is `q`.
+
+## Multiple values for the same key
+
+Declare the type as `list[str]` to collect all values:
+
+```python
+@app.get("/filter")
+async def filter_items(tags: list[str] = Query(default=[])):
+ return {"tags": tags}
+```
+
+```bash
+curl "http://localhost:8000/filter?tags=a&tags=b"
+# {"tags":["a","b"]}
+```
+
+## Next steps
+
+- [Request Body](request-body.md) — send structured data in the request body.
+- [Path Parameters](path-parameters.md) — dynamic URL segments.
diff --git a/docs/tutorial/request-body.md b/docs/tutorial/request-body.md
new file mode 100644
index 0000000..0fe0dfc
--- /dev/null
+++ b/docs/tutorial/request-body.md
@@ -0,0 +1,128 @@
+# Request Body
+
+A **request body** is data sent by the client in the HTTP request, typically as JSON.
+FasterAPI uses **[msgspec](https://jcristharif.com/msgspec/)** structs for fast,
+type-safe validation and serialisation — no Pydantic required.
+
+## Defining a model
+
+Subclass `msgspec.Struct` to describe the expected fields:
+
+```python
+import msgspec
+
+
+class Item(msgspec.Struct):
+ name: str
+ price: float
+ in_stock: bool = True
+```
+
+- Required fields have no default (`name`, `price`).
+- Optional fields have a default (`in_stock`).
+
+## Using the model in a route
+
+Declare the struct as a parameter. FasterAPI reads the JSON body, validates it, and
+passes an `Item` instance to your handler:
+
+```python
+from FasterAPI import Faster
+import msgspec
+
+app = Faster()
+
+
+class Item(msgspec.Struct):
+ name: str
+ price: float
+ in_stock: bool = True
+
+
+@app.post("/items")
+async def create_item(item: Item):
+ return {"name": item.name, "price": item.price}
+```
+
+```bash
+curl -X POST http://localhost:8000/items \
+ -H "Content-Type: application/json" \
+ -d '{"name": "Widget", "price": 9.99}'
+# {"name":"Widget","price":9.99}
+```
+
+## Path + query + body together
+
+```python
+@app.put("/items/{item_id}")
+async def update_item(item_id: int, item: Item, notify: bool = False):
+ result = {"item_id": item_id, **msgspec.structs.asdict(item)}
+ if notify:
+ result["message"] = "notified"
+ return result
+```
+
+- `item_id` comes from the path.
+- `notify` comes from the query string.
+- `item` comes from the JSON body.
+
+## Nested models
+
+```python
+class Image(msgspec.Struct):
+ url: str
+ width: int
+ height: int
+
+
+class Product(msgspec.Struct):
+ name: str
+ image: Image | None = None
+```
+
+## Optional body
+
+Use `| None` with a default to make the body optional:
+
+```python
+@app.patch("/items/{item_id}")
+async def partial_update(item_id: int, item: Item | None = None):
+ if item is None:
+ return {"item_id": item_id, "updated": False}
+ return {"item_id": item_id, "name": item.name}
+```
+
+## Validation errors
+
+If the client sends invalid JSON or a field fails type coercion, FasterAPI returns:
+
+```json
+{
+ "detail": [
+ {
+ "loc": ["body"],
+ "msg": "Expected `float`, got `str` - at `$.price`",
+ "type": "value_error.msgspec"
+ }
+ ]
+}
+```
+
+## Using `Body()` for raw JSON
+
+For cases where you want an arbitrary JSON value rather than a struct:
+
+```python
+from FasterAPI import Body
+
+
+@app.post("/raw")
+async def echo(data: dict = Body()):
+ return data
+```
+
+## Next steps
+
+- [Response Model](response-model.md) — control what data is returned to the client.
+- [Form Data & File Uploads](form-and-files.md) — non-JSON request bodies.
+- [Dependencies](dependencies.md) — share validation logic across routes.
diff --git a/docs/tutorial/response-model.md b/docs/tutorial/response-model.md
new file mode 100644
index 0000000..b2042ae
--- /dev/null
+++ b/docs/tutorial/response-model.md
@@ -0,0 +1,137 @@
+# Response Model
+
+FasterAPI serialises whatever you return from a route handler. For tight control over
+the shape of the response, annotate the **return type** and use `response_model` or
+`status_code` in the decorator.
+
+## Return type annotation
+
+```python
+import msgspec
+from FasterAPI import Faster
+
+app = Faster()
+
+
+class Item(msgspec.Struct):
+ id: int
+ name: str
+ price: float
+
+
+@app.get("/items/{item_id}")
+async def get_item(item_id: int) -> Item:
+ return Item(id=item_id, name="Widget", price=9.99)
+```
+
+FasterAPI uses `msgspec.json.encode` to serialise the struct directly — no
+intermediate dict conversion.
+
+## Custom status codes
+
+```python
+@app.post("/items", status_code=201)
+async def create_item(item: Item) -> Item:
+ return item
+```
+
+Common status codes:
+
+| Code | Meaning |
+|------|---------|
+| 200 | OK (default) |
+| 201 | Created |
+| 204 | No Content |
+| 400 | Bad Request |
+| 404 | Not Found |
+| 422 | Unprocessable Entity |
+
+## Returning `None` / no body (204)
+
+```python
+@app.delete("/items/{item_id}", status_code=204)
+async def delete_item(item_id: int) -> None:
+ # delete logic here
+ pass
+```
+
+## Returning a list
+
+```python
+@app.get("/items")
+async def list_items() -> list[Item]:
+ return [Item(id=1, name="alpha", price=1.0)]
+```
+
+## Response classes
+
+Return any response class directly to take full control:
+
+```python
+from FasterAPI import JSONResponse, HTMLResponse, PlainTextResponse
+
+
+@app.get("/json")
+async def as_json():
+ return JSONResponse({"key": "value"}, status_code=200)
+
+
+@app.get("/html")
+async def as_html():
+ return HTMLResponse("Hello
")
+
+
+@app.get("/text")
+async def as_text():
+ return PlainTextResponse("Hello, world!")
+```
+
+## File responses
+
+```python
+from FasterAPI import FileResponse
+
+
+@app.get("/download")
+async def download():
+ return FileResponse("report.pdf", filename="my-report.pdf")
+```
+
+## Streaming responses
+
+```python
+import asyncio
+from FasterAPI import StreamingResponse
+
+
+async def event_stream():
+ for i in range(5):
+ yield f"chunk {i}\n".encode()
+ await asyncio.sleep(0.1)
+
+
+@app.get("/stream")
+async def stream():
+ return StreamingResponse(event_stream(), media_type="text/plain")
+```
+
+## Additional responses in OpenAPI
+
+Document alternative response shapes in the decorator:
+
+```python
+class ErrorDetail(msgspec.Struct):
+ detail: str
+
+
+@app.get(
+ "/items/{item_id}",
+ responses={404: {"description": "Item not found"}},
+)
+async def get_item(item_id: int) -> Item: ...
+```
+
+## Next steps
+
+- [Error Handling](error-handling.md) — return standardised errors.
+- [Advanced: Custom Response](../advanced/custom-response.md) — full response control.
diff --git a/docs/tutorial/websockets.md b/docs/tutorial/websockets.md
new file mode 100644
index 0000000..c353b60
--- /dev/null
+++ b/docs/tutorial/websockets.md
@@ -0,0 +1,125 @@
+# WebSockets
+
+FasterAPI supports WebSocket connections natively. Use the `@app.websocket` decorator
+and the `WebSocket` class for full duplex, low-latency communication.
+
+## Basic WebSocket endpoint
+
+```python
+from FasterAPI import Faster, WebSocket
+
+app = Faster()
+
+
+@app.websocket("/ws")
+async def websocket_endpoint(ws: WebSocket):
+ await ws.accept()
+ while True:
+ data = await ws.receive_text()
+ await ws.send_text(f"Echo: {data}")
+```
+
+Test it with a browser console:
+
+```javascript
+const ws = new WebSocket("ws://localhost:8000/ws");
+ws.onmessage = e => console.log(e.data);
+ws.send("hello");
+// logs: "Echo: hello"
+```
+
+## Receiving data
+
+```python
+# Text frames
+text = await ws.receive_text()
+
+# Binary frames
+data = await ws.receive_bytes()
+
+# Raw ASGI message dict
+msg = await ws.receive()
+```
+
+## Sending data
+
+```python
+await ws.send_text("hello")
+await ws.send_bytes(b"\x00\x01")
+await ws.send_json({"type": "update", "payload": 42})
+```
+
+## Disconnection handling
+
+`WebSocketDisconnect` is raised when the client closes the connection:
+
+```python
+from FasterAPI import WebSocketDisconnect
+
+
+@app.websocket("/chat")
+async def chat(ws: WebSocket):
+ await ws.accept()
+ try:
+ while True:
+ msg = await ws.receive_text()
+ await ws.send_text(f"You said: {msg}")
+ except WebSocketDisconnect:
+ print("Client disconnected")
+```
+
+## Path parameters in WebSocket routes
+
+```python
+@app.websocket("/rooms/{room_id}")
+async def room(ws: WebSocket, room_id: str):
+ await ws.accept()
+ await ws.send_text(f"Joined room {room_id}")
+ await ws.close()
+```
+
+## Broadcast pattern
+
+```python
+from FasterAPI import WebSocket, WebSocketDisconnect
+
+connections: list[WebSocket] = []
+
+
+@app.websocket("/broadcast")
+async def broadcast_endpoint(ws: WebSocket):
+ await ws.accept()
+ connections.append(ws)
+ try:
+ while True:
+ msg = await ws.receive_text()
+ for conn in list(connections):
+ try:
+ await conn.send_text(msg)
+ except Exception:
+ connections.remove(conn)
+ except WebSocketDisconnect:
+ connections.remove(ws)
+```
+
+## WebSocket state
+
+```python
+from FasterAPI import WebSocketState
+
+if ws.client_state == WebSocketState.CONNECTED:
+ await ws.send_text("still here")
+```
+
+States: `CONNECTING`, `CONNECTED`, `DISCONNECTED`.
+
+## Closing with a code
+
+```python
+await ws.close(code=1008) # Policy Violation
+```
+
+## Next steps
+
+- [Metadata & Docs](metadata.md) — tag and describe your routes.
+- [Advanced: Server-Sent Events](../advanced/server-sent-events.md) — one-way streaming.
diff --git a/mkdocs.yml b/mkdocs.yml
index 6d21d50..574efbf 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -29,28 +29,89 @@ theme:
icon: material/brightness-4
name: Switch to light mode
features:
+ - navigation.tabs
+ - navigation.tabs.sticky
- navigation.expand
- navigation.sections
+ - navigation.top
- content.code.copy
+ - toc.follow
nav:
- Home: index.md
- - Getting started: getting-started.md
- - Python 3.13 & compatibility: python-313.md
- - Tutorial (CRUD app): tutorial-crud.md
- - Migrating from FastAPI: migration-from-fastapi.md
- - Benchmarks: benchmarks.md
- - API reference: api-reference.md
- - Acknowledgments: acknowledgments.md
+ - Getting Started: getting-started.md
+ - Tutorial:
+ - Overview: tutorial/index.md
+ - Path Parameters: tutorial/path-parameters.md
+ - Query Parameters: tutorial/query-parameters.md
+ - Request Body: tutorial/request-body.md
+ - Response Model: tutorial/response-model.md
+ - Form Data & File Uploads: tutorial/form-and-files.md
+ - Error Handling: tutorial/error-handling.md
+ - Dependencies: tutorial/dependencies.md
+ - Background Tasks: tutorial/background-tasks.md
+ - Middleware: tutorial/middleware.md
+ - WebSockets: tutorial/websockets.md
+ - Metadata & Docs: tutorial/metadata.md
+ - Advanced User Guide:
+ - Overview: advanced/index.md
+ - Custom Response Classes: advanced/custom-response.md
+ - Response Cookies & Headers: advanced/response-cookies-headers.md
+ - Using the Request: advanced/using-request.md
+ - Settings & Env Vars: advanced/settings.md
+ - OpenAPI Customisation: advanced/openapi-customization.md
+ - Templates (Jinja2): advanced/templates.md
+ - Lifespan Events: advanced/lifespan.md
+ - Behind a Proxy: advanced/behind-proxy.md
+ - Sub-applications: advanced/sub-applications.md
+ - Server-Sent Events: advanced/server-sent-events.md
+ - Testing with Overrides: advanced/testing-overrides.md
+ - Async Tests: advanced/async-tests.md
+ - Bigger Applications: advanced/bigger-apps.md
+ - Security:
+ - Introduction: security/index.md
+ - OAuth2 + JWT: security/oauth2-jwt.md
+ - OAuth2 Scopes: security/oauth2-scopes.md
+ - HTTP Basic Auth: security/http-basic-auth.md
+ - Databases:
+ - Overview: database/index.md
+ - SQL with SQLAlchemy: database/sqlalchemy.md
+ - NoSQL — MongoDB: database/nosql-mongodb.md
+ - Async Database Usage: database/async-db.md
+ - Deployment:
+ - Overview: deployment/index.md
+ - Docker: deployment/docker.md
+ - Nginx & Traefik: deployment/nginx-traefik.md
+ - Cloud Services: deployment/cloud.md
+ - Kubernetes: deployment/kubernetes.md
+ - Concepts:
+ - Async / Await: concepts/async-await.md
+ - Concurrency & Parallelism: concepts/concurrency.md
+ - Python Type Hints: concepts/types-intro.md
+ - How-To Recipes: how-to/index.md
+ - Reference:
+ - API Reference: api-reference.md
+ - Benchmarks: benchmarks.md
+ - Migrating from FastAPI: migration-from-fastapi.md
+ - Python 3.13 & Compatibility: python-313.md
+ - Changelog: changelog.md
+ - Acknowledgments: acknowledgments.md
+ - FAQ & Troubleshooting: faq.md
markdown_extensions:
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.superfences
- pymdownx.inlinehilite
+ - pymdownx.tabbed:
+ alternate_style: true
- admonition
+ - pymdownx.details
- toc:
permalink: true
+ - attr_list
+ - md_in_html
+ - tables
extra:
social: