Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 41 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,26 +63,51 @@ uv run uvicorn your_module:app --reload

## Built-in routes

- `GET /` β€” welcome message confirming the app is reachable.
- `GET /health` β€” returns `{"status": "healthy"}` for load balancer and deployment checks.
<!-- BEGIN ROUTES -->
- `GET /` β€” Welcome
- `GET /health` β€” Health

### Session
- `GET /auth/session` β€” returns the current user session details.
### Auth

### Background Jobs
- `GET /jobs` β€” list jobs.
- `POST /jobs` β€” enqueue a background job.
- `GET /jobs/{job_id}` β€” get the status and result of a job.
- `GET /auth/session` β€” Current session

### Jobs

- `GET /jobs` β€” List jobs
- `POST /jobs` β€” Enqueue a job
- `GET /jobs/{job_id}` β€” Get job

### Llm

- `POST /llm/chat` β€” Chat completion
- `POST /llm/chat/stream` β€” Streaming chat completion

### Passkey

- `POST /auth/passkey/register/start` β€” Start passkey registration
- `POST /auth/passkey/register/finish` β€” Finish passkey registration
- `POST /auth/passkey/login/start` β€” Start passkey login
- `POST /auth/passkey/login/finish` β€” Finish passkey login
- `POST /auth/passkey/add/start` β€” Start adding a passkey
- `POST /auth/passkey/add/finish` β€” Finish adding a passkey
- `GET /auth/passkeys` β€” List passkeys
- `POST /auth/passkeys/{key_id}/revoke` β€” Revoke a passkey
- `PATCH /auth/passkeys/{key_id}` β€” Rename a passkey

### Password Auth

- `POST /auth/register` β€” Register with password
- `POST /auth/login` β€” Login with password
- `POST /auth/password-reset/request` β€” Request a password reset
- `POST /auth/password-reset/confirm` β€” Confirm password reset

### Uploads
- `GET /uploads` β€” list uploaded files.
- `POST /uploads` β€” upload a new file.
- `GET /uploads/{upload_id}` β€” get metadata for a specific upload.
- `GET /uploads/{upload_id}/download` β€” download the uploaded file.

### LLM Chat
- `POST /llm/chat` β€” send a message to the language model.
- `POST /llm/chat/stream` β€” stream responses from the language model.

- `GET /uploads` β€” List uploads
- `POST /uploads` β€” Upload a file
- `GET /uploads/{upload_id}` β€” Get upload metadata
- `GET /uploads/{upload_id}/download` β€” Download a file
<!-- END ROUTES -->

## Auth model

Expand Down
127 changes: 69 additions & 58 deletions scripts/check_doc_routes.py
Original file line number Diff line number Diff line change
@@ -1,91 +1,102 @@
#!/usr/bin/env -S uv run python
"""Drift-prevention check: verify that every API route in the FastAPI app is documented.
"""Drift-prevention check: verify that API routes in README.md are generated and up-to-date.

Usage (from repo root):
uv run scripts/check_doc_routes.py
uv run scripts/check_doc_routes.py [--update]

The script imports the h4ckath0n app, enumerates all routes, and checks that
README.md mentions each one. Routes provided by FastAPI itself (e.g. /openapi.json,
/docs, /redoc) are excluded from the check.
The script imports the h4ckath0n app, generates a markdown list of all routes,
and ensures that README.md contains the exact generated text between
<!-- BEGIN ROUTES --> and <!-- END ROUTES -->.
"""

from __future__ import annotations

import re
import argparse
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
README = REPO_ROOT / "README.md"

# FastAPI paths omitted from user docs.
FRAMEWORK_PATHS = frozenset(
{"/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
)


def get_app_routes() -> list[tuple[str, str]]:
"""Return (method, path) pairs from the live FastAPI app."""
from h4ckath0n.app import create_app # noqa: E402
from h4ckath0n.config import Settings # noqa: E402
def generate_routes_md() -> str:
from h4ckath0n.app import create_app
from h4ckath0n.config import Settings

settings = Settings(
database_url="sqlite+aiosqlite://",
password_auth_enabled=True,
)
app = create_app(settings)
paths = app.openapi().get("paths", {})

routes: list[tuple[str, str]] = []
for route in app.routes:
# Skip non-HTTP routes.
if not hasattr(route, "methods") or not hasattr(route, "path"):
continue
path: str = route.path # type: ignore[union-attr]
if path in FRAMEWORK_PATHS:
continue
for method in sorted(route.methods): # type: ignore[union-attr]
if method == "HEAD":
continue
routes.append((method, path))
return sorted(routes)


def check_routes_in_readme(
routes: list[tuple[str, str]],
) -> list[tuple[str, str]]:
"""Return routes that are not mentioned anywhere in README.md.

We look for ``METHOD /path`` (e.g. ``GET /health``) so that sub-path
matches like ``/auth/passkeys/{key_id}`` inside
``/auth/passkeys/{key_id}/revoke`` are not false positives.
"""
readme_text = README.read_text()
missing: list[tuple[str, str]] = []
for method, path in routes:
# Match exact method/path tokens in README.
path_re = re.escape(path)
combined = rf"`{method}\s+{path_re}`"
if not re.search(combined, readme_text, re.IGNORECASE):
missing.append((method, path))
return missing
routes_by_tag: dict[str, list[str]] = {}
for path, methods in paths.items():
for method, op in methods.items():
tags = op.get("tags", ["default"])
tag = tags[0] if tags else "default"
if tag not in routes_by_tag:
routes_by_tag[tag] = []

summary = op.get("summary", "")
routes_by_tag[tag].append(f"- `{method.upper()} {path}` β€” {summary}")

lines: list[str] = []

tags = sorted(routes_by_tag.keys())
if "default" in tags:
tags.remove("default")
tags.insert(0, "default")

for tag in tags:
if tag != "default":
title = tag.replace("-", " ").title()
lines.append(f"### {title}\n")
for route in routes_by_tag[tag]:
lines.append(route)
lines.append("")

return "\n".join(lines).strip() + "\n"


def main() -> int:
routes = get_app_routes()
missing = check_routes_in_readme(routes)
parser = argparse.ArgumentParser()
parser.add_argument("--update", action="store_true", help="Update README.md inline")
args = parser.parse_args()

expected_content = generate_routes_md()

readme_text = README.read_text()

begin_marker = "<!-- BEGIN ROUTES -->\n"
end_marker = "<!-- END ROUTES -->"

if missing:
print("❌ The following API routes are NOT documented in README.md:\n")
for method, path in missing:
print(f" {method:6s} {path}")
if begin_marker not in readme_text or end_marker not in readme_text:
print(
"\nAdd these routes to README.md or, if intentionally undocumented, "
"add them to FRAMEWORK_PATHS in this script."
"❌ Could not find <!-- BEGIN ROUTES --> or <!-- END ROUTES --> in README.md"
)
return 1

print(f"βœ… All {len(routes)} API routes are documented in README.md.")
return 0
start_idx = readme_text.find(begin_marker) + len(begin_marker)
end_idx = readme_text.find(end_marker)

actual_content = readme_text[start_idx:end_idx]

if actual_content == expected_content:
print("βœ… API routes in README.md are up-to-date.")
return 0
else:
if args.update:
new_readme_text = (
readme_text[:start_idx] + expected_content + readme_text[end_idx:]
)
README.write_text(new_readme_text)
print("βœ… Updated API routes in README.md.")
return 0
else:
print("❌ API routes in README.md are out of date.")
print("Run `uv run scripts/check_doc_routes.py --update` to fix.")
return 1


if __name__ == "__main__":
Expand Down