Initial commit: API gateway with admin console

- FastAPI async gateway with httpx proxying to multiple upstreams
- SQLite database with SQLAlchemy ORM
- Admin console: manage services, users, API keys, endpoint access
- Per-key, per-endpoint granular access control
- OpenAPI document sync and caching (5-minute TTL)
- Request/response logging with full transaction inspection
- In-memory rate limiting (per-key, fixed-window)
- Tiered log retention (7d payloads, 90d rows, incremental vacuum)
- TLS verification toggle per service (for self-signed certificates)
- Service connectivity validation with automatic endpoint refresh
- Request browser with filters and deep-link inspection
- Docker setup with persistent volume
- Modal forms for create/edit flows

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Samuel Amar
2026-07-29 14:00:22 +02:00
co-authored by Claude Haiku 4.5
commit 77d7a50fa9
31 changed files with 2927 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "gateway",
"runtimeExecutable": ".venv\\Scripts\\python.exe",
"runtimeArgs": ["-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090"],
"port": 8090
}
]
}
+11
View File
@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.pyc
gateway.db
cookies.txt
.claude/
.git/
.gitignore
docker-compose.yml
Dockerfile
README.md
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
gateway.db
cookies.txt
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>API gatweay</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
</buildSpec>
<natures>
</natures>
</projectDescription>
+25
View File
@@ -0,0 +1,25 @@
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
# Non-root user; the SQLite database lives on the /data volume
RUN useradd --create-home gateway && mkdir /data && chown gateway:gateway /data
USER gateway
ENV GATEWAY_DATABASE_URL=sqlite:////data/gateway.db
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"
# Single worker on purpose: the rate limiter and sync cache are in-memory.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+99
View File
@@ -0,0 +1,99 @@
# API Gateway
A FastAPI-based gateway that exposes a single entrypoint for multiple upstream APIs,
with a built-in web management console.
## Features
- **Single entrypoint** — consumers call `/<service-slug>/<path>` and the gateway
proxies to the registered upstream (all HTTP methods, query strings, bodies,
headers). `/admin`, `/static`, `/docs` and `/health` are reserved.
- **Endpoint catalog, auto-synced** — each service's callable endpoints are
mirrored from its OpenAPI document (`/openapi.json`, `/swagger.json` or
`/api-docs`) whenever the API Keys page loads: new operations appear, removed
ones are deleted (their grants with them). Services without a reachable spec
keep their existing catalog. Patterns support `{param}` (one segment), `*`
(within a segment) and `**` (any depth).
- **Per-key access rights, hierarchical picker** — every API key is granted a
specific set of endpoints, chosen in a path tree (service → path segments →
operations) where a parent checkbox selects everything beneath it. A request
must match a listed endpoint AND the key must hold a grant on it; otherwise
404 (`unknown_endpoint`) or 403 (`access_denied`). Keys are hashed at rest,
shown once at creation, revocable, with per-key rate limits.
- **Web management console** (`/admin`) — manage connected APIs, users, and API
keys; usage dashboards with charts. Every console user has full management
access.
- **Transaction capture & request browser** — every gateway request is recorded
with service, endpoint, key, status, latency, client IP, query string, and
the request/response payloads (textual bodies up to 64 KB; binary summarized).
The Requests page filters by service / user / key / status class / path and
links to a per-request detail view showing the complete transaction.
- **Usage monitoring** — charts: traffic and average latency over time (adaptive
buckets: 5 min / 1 h / 1 day), volume by service / user / endpoint, status
codes, latency by service, top keys — filterable by time range (1 h 30 d),
service, user and key.
- **Retention** — a background purge runs every 6 hours: request/response
payloads are blanked after 7 days (the log row stays inspectable), whole log
rows are deleted after 90 days, and freed pages are returned to the
filesystem via incremental vacuum. Both windows are configurable; `0`
disables that tier.
## Quick start
```bash
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Open http://localhost:8000/admin — first startup seeds an admin account
(`admin` / `admin` by default — **change it immediately**).
## Docker
```bash
docker compose up -d
```
The console is served on http://localhost:8000/admin; the SQLite database
persists in the `gateway-data` volume. Set `GATEWAY_SECRET_KEY` (and the admin
credentials) in `docker-compose.yml` before deploying anywhere real. The image
runs a single uvicorn worker on purpose — the rate limiter and the OpenAPI
sync cache are in-memory.
## Configuration (environment variables)
| Variable | Default | Purpose |
|---|---|---|
| `GATEWAY_DATABASE_URL` | `sqlite:///./gateway.db` | SQLAlchemy database URL |
| `GATEWAY_SECRET_KEY` | dev value | Session cookie signing key — set in production |
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | `admin` / `admin` | Seeded admin credentials |
| `GATEWAY_PAYLOAD_RETENTION_DAYS` | `7` | Blank stored payloads after N days (0 = keep forever) |
| `GATEWAY_LOG_RETENTION_DAYS` | `90` | Delete log rows after N days (0 = keep forever) |
## Using the gateway as a consumer
1. Register a service (e.g. slug `weather``https://api.example.com/v1`) and
list its endpoints (import from OpenAPI or add manually).
2. Issue an API key and tick the endpoints it may call.
3. The consumer calls:
```bash
curl -H "X-API-Key: gw_..." "http://localhost:8000/weather/forecast?city=Paris"
```
The gateway authenticates the key, matches the request against the service's
endpoint list, checks the key's grant and the rate limit, proxies the request to
`https://api.example.com/v1/forecast?city=Paris`, logs it, and returns the
upstream response. Gateway-generated errors are JSON with an `error` code:
`missing_api_key` (401), `invalid_api_key` / `access_denied` (403),
`unknown_service` / `unknown_endpoint` (404), `rate_limited` (429),
`upstream_unreachable` (502), `upstream_timeout` (504).
## Notes & limits
- The stats queries use SQLite date functions; if you point `GATEWAY_DATABASE_URL`
at Postgres/MySQL, adapt `app/admin/stats.py` (`strftime`, `iif`).
- The rate limiter is in-memory (single process). Run one worker, or swap in Redis
for multi-worker deployments.
- Streaming responses are buffered (fine for JSON APIs; not for large file proxying).
View File
View File
+29
View File
@@ -0,0 +1,29 @@
from fastapi import Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app import config, security
from app.database import get_db
from app.models import User
class LoginRequired(HTTPException):
"""Raised when there is no valid session; handled by redirecting to /admin/login."""
def __init__(self):
super().__init__(status_code=status.HTTP_303_SEE_OTHER)
def login_redirect_handler(request: Request, exc: LoginRequired):
return RedirectResponse("/admin/login", status_code=303)
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
token = request.cookies.get(config.SESSION_COOKIE)
user_id = security.read_session_token(token) if token else None
if user_id is None:
raise LoginRequired()
user = db.get(User, user_id)
if user is None or not user.is_active:
raise LoginRequired()
return user
+423
View File
@@ -0,0 +1,423 @@
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import func
from sqlalchemy.orm import Session
from app import config, discovery, security
from app.admin.deps import current_user
from app.database import get_db
from app.models import ApiKey, Endpoint, RequestLog, Service, User
router = APIRouter(prefix="/admin")
templates = Jinja2Templates(directory=str(config.BASE_DIR / "app" / "templates"))
# Slugs the proxy catch-all must never claim
RESERVED_SLUGS = {"admin", "static", "health", "docs", "redoc", "openapi.json"}
def build_tree(endpoints: list[Endpoint]) -> dict:
"""Nest endpoints by path segment for the hierarchical access picker.
Node: {name, children: {segment: node}, endpoints: [Endpoint]}.
Chains of empty single-child nodes are compressed ('api' + 'v1' -> 'api/v1')."""
root = {"name": "", "children": {}, "endpoints": []}
for e in endpoints:
node = root
for part in (p for p in e.path.split("/") if p):
node = node["children"].setdefault(
part, {"name": part, "children": {}, "endpoints": []})
node["endpoints"].append(e)
def compress(node: dict) -> None:
for key in list(node["children"]):
child = node["children"][key]
while not child["endpoints"] and len(child["children"]) == 1:
(grandchild,) = child["children"].values()
child["name"] = child["name"] + "/" + grandchild["name"]
child["endpoints"] = grandchild["endpoints"]
child["children"] = grandchild["children"]
compress(child)
if child["name"] != key:
node["children"][child["name"]] = node["children"].pop(key)
compress(root)
return root
def render(request: Request, name: str, user: User | None = None, **ctx):
return templates.TemplateResponse(
request, name, {"user": user, "active": name.split(".")[0], **ctx}
)
def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url, status_code=303)
# ---------- auth ----------
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return render(request, "login.html")
@router.post("/login")
def login(request: Request, username: str = Form(...), password: str = Form(...),
db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == username).one_or_none()
if not user or not user.is_active or not security.verify_password(password, user.password_hash):
return render(request, "login.html", error="Invalid username or password.")
response = _redirect("/admin")
response.set_cookie(
config.SESSION_COOKIE,
security.create_session_token(user.id),
max_age=config.SESSION_MAX_AGE,
httponly=True,
samesite="lax",
)
return response
@router.get("/logout")
def logout():
response = _redirect("/admin/login")
response.delete_cookie(config.SESSION_COOKIE)
return response
# ---------- dashboard ----------
@router.get("", response_class=HTMLResponse)
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db)):
recent = (
db.query(RequestLog).order_by(RequestLog.timestamp.desc()).limit(15).all()
)
return render(request, "dashboard.html", user, recent=recent)
# ---------- services & endpoints ----------
@router.get("/services", response_class=HTMLResponse)
def services_page(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db)):
services = db.query(Service).order_by(Service.name).all()
counts = dict(
db.query(RequestLog.service_id, func.count(RequestLog.id))
.group_by(RequestLog.service_id).all()
)
return render(request, "services.html", user, services=services, counts=counts)
@router.post("/services")
def create_service(name: str = Form(...), slug: str = Form(...), base_url: str = Form(...),
description: str = Form(""), timeout_seconds: float = Form(30.0),
verify_tls: bool = Form(False),
user: User = Depends(current_user), db: Session = Depends(get_db)):
slug = slug.strip().lower()
if slug in RESERVED_SLUGS:
raise HTTPException(400, f"Slug '{slug}' is reserved by the gateway itself.")
if db.query(Service).filter(Service.slug == slug).count():
raise HTTPException(400, f"Slug '{slug}' is already taken.")
service = Service(name=name.strip(), slug=slug, base_url=base_url.strip().rstrip("/"),
description=description.strip(), timeout_seconds=timeout_seconds,
verify_tls=verify_tls)
db.add(service)
db.commit()
# The page auto-validates the new service (which also caches its endpoints).
return _redirect(f"/admin/services?validate={service.id}")
@router.post("/services/{service_id}/update")
def update_service(service_id: int, name: str = Form(...), base_url: str = Form(...),
description: str = Form(""), timeout_seconds: float = Form(30.0),
verify_tls: bool = Form(False),
user: User = Depends(current_user), db: Session = Depends(get_db)):
service = db.get(Service, service_id)
if not service:
raise HTTPException(404)
service.name, service.base_url = name.strip(), base_url.strip().rstrip("/")
service.description, service.timeout_seconds = description.strip(), timeout_seconds
service.verify_tls = verify_tls
db.commit()
return _redirect(f"/admin/services?validate={service.id}")
@router.post("/services/{service_id}/validate")
def validate_service(service_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
"""Probe the upstream; if it publishes an OpenAPI document this also
refreshes the endpoint cache."""
service = db.get(Service, service_id)
if not service:
raise HTTPException(404)
return discovery.validate_service(db, service)
@router.post("/services/{service_id}/toggle")
def toggle_service(service_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
service = db.get(Service, service_id)
if not service:
raise HTTPException(404)
service.is_active = not service.is_active
db.commit()
return _redirect("/admin/services")
@router.post("/services/{service_id}/delete")
def delete_service(service_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
service = db.get(Service, service_id)
if service:
db.delete(service)
db.commit()
return _redirect("/admin/services")
# ---------- users ----------
@router.get("/users", response_class=HTMLResponse)
def users_page(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db)):
users = db.query(User).order_by(User.username).all()
return render(request, "users.html", user, users=users)
@router.post("/users")
def create_user(username: str = Form(...), password: str = Form(...),
user: User = Depends(current_user), db: Session = Depends(get_db)):
username = username.strip()
if db.query(User).filter(User.username == username).count():
raise HTTPException(400, f"Username '{username}' is already taken.")
db.add(User(username=username, password_hash=security.hash_password(password)))
db.commit()
return _redirect("/admin/users")
@router.post("/users/{user_id}/toggle")
def toggle_user(user_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
target = db.get(User, user_id)
if not target:
raise HTTPException(404)
if target.id == user.id:
raise HTTPException(400, "You cannot deactivate your own account.")
target.is_active = not target.is_active
db.commit()
return _redirect("/admin/users")
@router.post("/users/{user_id}/password")
def reset_password(user_id: int, password: str = Form(...),
user: User = Depends(current_user), db: Session = Depends(get_db)):
target = db.get(User, user_id)
if not target:
raise HTTPException(404)
target.password_hash = security.hash_password(password)
db.commit()
return _redirect("/admin/users")
@router.post("/users/{user_id}/delete")
def delete_user(user_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
if user_id == user.id:
raise HTTPException(400, "You cannot delete your own account.")
target = db.get(User, user_id)
if target:
db.delete(target)
db.commit()
return _redirect("/admin/users")
# ---------- API keys / endpoint access ----------
@router.get("/keys", response_class=HTMLResponse)
def keys_page(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db)):
services = db.query(Service).order_by(Service.name).all()
# Rendered straight from the database — the OpenAPI sync runs in the
# background via /admin/api/endpoints/sync, triggered by the page's JS.
trees = {s.id: build_tree(s.endpoints) for s in services}
synced = {s.id: discovery.spec_status(s.id) for s in services}
keys = db.query(ApiKey).order_by(ApiKey.created_at.desc()).all()
users = db.query(User).filter(User.is_active).order_by(User.username).all()
new_key = request.query_params.get("new_key")
return render(request, "keys.html", user, keys=keys, users=users,
services=services, trees=trees, synced=synced, new_key=new_key)
@router.get("/api/endpoints/sync")
def sync_endpoints(force: bool = False, user: User = Depends(current_user),
db: Session = Depends(get_db)):
"""Refresh all endpoint catalogs from their OpenAPI documents (TTL-cached).
The keys page calls this in the background and reloads if anything changed."""
changed_any = False
specs = {}
for service in db.query(Service).order_by(Service.name).all():
found, changed = discovery.sync_service(db, service, force=force)
specs[service.id] = found
changed_any = changed_any or changed
return {"changed": changed_any, "specs": specs}
@router.post("/keys")
def create_key(request: Request, name: str = Form(...), user_id: int = Form(...),
rate_limit_per_minute: int = Form(60),
endpoint_ids: list[int] = Form([]),
user: User = Depends(current_user), db: Session = Depends(get_db)):
owner = db.get(User, user_id)
if not owner:
raise HTTPException(400, "Unknown user.")
plain, prefix, key_hash = security.generate_api_key()
key = ApiKey(user_id=owner.id, name=name.strip(), prefix=prefix, key_hash=key_hash,
rate_limit_per_minute=max(0, rate_limit_per_minute))
key.endpoints = db.query(Endpoint).filter(
Endpoint.id.in_(endpoint_ids)).all() if endpoint_ids else []
db.add(key)
db.commit()
# Shown once on the next page load; never stored in plain text.
return _redirect(f"/admin/keys?new_key={plain}")
@router.post("/keys/{key_id}/access")
def update_key_access(key_id: int, endpoint_ids: list[int] = Form([]),
rate_limit_per_minute: int = Form(60),
user: User = Depends(current_user), db: Session = Depends(get_db)):
key = db.get(ApiKey, key_id)
if not key:
raise HTTPException(404)
key.endpoints = db.query(Endpoint).filter(
Endpoint.id.in_(endpoint_ids)).all() if endpoint_ids else []
key.rate_limit_per_minute = max(0, rate_limit_per_minute)
db.commit()
return _redirect("/admin/keys")
@router.post("/keys/{key_id}/toggle")
def toggle_key(key_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
key = db.get(ApiKey, key_id)
if not key:
raise HTTPException(404)
key.is_active = not key.is_active
db.commit()
return _redirect("/admin/keys")
@router.post("/keys/{key_id}/delete")
def delete_key(key_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
key = db.get(ApiKey, key_id)
if key:
db.delete(key)
db.commit()
return _redirect("/admin/keys")
# ---------- request browser ----------
PAGE_SIZE = 50
def _int_or_none(value: str | None) -> int | None:
"""HTML GET forms submit empty strings for untouched fields — treat
anything non-numeric as 'no filter' instead of a validation error."""
try:
return int(value) if value else None
except ValueError:
return None
@router.get("/requests", response_class=HTMLResponse)
def requests_page(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db),
service_id: str | None = None, user_id: str | None = None,
key_id: str | None = None, status_class: str | None = None,
q: str | None = None, page: str | None = None):
service_id = _int_or_none(service_id)
user_id = _int_or_none(user_id)
key_id = _int_or_none(key_id)
page = _int_or_none(page) or 1
query = db.query(RequestLog)
if service_id:
query = query.filter(RequestLog.service_id == service_id)
if key_id:
query = query.filter(RequestLog.api_key_id == key_id)
if user_id:
query = query.join(ApiKey, RequestLog.api_key_id == ApiKey.id).filter(
ApiKey.user_id == user_id)
if status_class in ("2", "3", "4", "5"):
low = int(status_class) * 100
query = query.filter(RequestLog.status_code >= low,
RequestLog.status_code < low + 100)
if q:
query = query.filter(RequestLog.path.contains(q))
total = query.count()
page = max(1, page)
logs = (query.order_by(RequestLog.timestamp.desc())
.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).all())
services = db.query(Service).order_by(Service.name).all()
users = db.query(User).order_by(User.username).all()
keys = db.query(ApiKey).order_by(ApiKey.name).all()
return render(request, "requests.html", user, logs=logs, total=total,
page=page, pages=max(1, -(-total // PAGE_SIZE)),
services=services, users=users, keys=keys,
f={"service_id": service_id, "user_id": user_id, "key_id": key_id,
"status_class": status_class or "", "q": q or ""})
def _pretty_json(text: str) -> str:
import json
try:
return json.dumps(json.loads(text), indent=2, ensure_ascii=False)
except (ValueError, TypeError):
return text
@router.get("/requests/{log_id}/data")
def request_data(log_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
"""Everything the inline request inspector needs, as JSON."""
log = db.get(RequestLog, log_id)
if not log:
raise HTTPException(404)
return {
"id": log.id,
"time": log.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
"status": log.status_code,
"latency_ms": round(log.latency_ms, 1),
"method": log.method,
"path": log.path,
"query_string": log.query_string,
"service": log.service.name if log.service else None,
"slug": log.service.slug if log.service else None,
"forwarded_to": (log.service.base_url + log.path +
("?" + log.query_string if log.query_string else ""))
if log.service else None,
"endpoint": f"{log.endpoint.method} {log.endpoint.path}" if log.endpoint else None,
"endpoint_description": log.endpoint.description if log.endpoint else "",
"key": log.api_key.name if log.api_key else None,
"key_prefix": log.api_key.prefix if log.api_key else "",
"user": log.api_key.user.username if log.api_key else None,
"client_ip": log.client_ip,
"request_body": _pretty_json(log.request_body),
"response_body": _pretty_json(log.response_body),
}
# ---------- monitoring ----------
@router.get("/monitoring", response_class=HTMLResponse)
def monitoring_page(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db)):
logs = db.query(RequestLog).order_by(RequestLog.timestamp.desc()).limit(100).all()
services = db.query(Service).order_by(Service.name).all()
users = db.query(User).order_by(User.username).all()
keys = db.query(ApiKey).order_by(ApiKey.name).all()
return render(request, "monitoring.html", user, logs=logs,
services=services, users=users, keys=keys)
+237
View File
@@ -0,0 +1,237 @@
"""JSON endpoints backing the monitoring charts.
All endpoints accept:
hours — window size (1..720)
service_id — restrict to one service
user_id — restrict to keys owned by one user
key_id — restrict to one API key
Time series use adaptive buckets: 5 min (<=6 h), 1 h (<=48 h), 1 day beyond.
SQLite-specific date functions are used (see README).
"""
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import Integer, func
from sqlalchemy.orm import Query as OrmQuery, Session
from app.admin.deps import current_user
from app.database import get_db
from app.models import ApiKey, Endpoint, RequestLog, Service, User
router = APIRouter(prefix="/admin/api", dependencies=[Depends(current_user)])
class Filters:
def __init__(
self,
hours: int = Query(24, ge=1, le=720),
service_id: int | None = Query(None),
user_id: int | None = Query(None),
key_id: int | None = Query(None),
):
self.hours = hours
self.since = datetime.now(timezone.utc) - timedelta(hours=hours)
self.service_id = service_id
self.user_id = user_id
self.key_id = key_id
def apply(self, q: OrmQuery, joined_key: bool = False) -> OrmQuery:
"""Apply window + filters. Pass joined_key when the query already
joins ApiKey so we don't join twice."""
q = q.filter(RequestLog.timestamp >= self.since)
if self.service_id:
q = q.filter(RequestLog.service_id == self.service_id)
if self.key_id:
q = q.filter(RequestLog.api_key_id == self.key_id)
if self.user_id:
if not joined_key:
q = q.join(ApiKey, RequestLog.api_key_id == ApiKey.id)
q = q.filter(ApiKey.user_id == self.user_id)
return q
def _bucket_seconds(hours: int) -> int:
if hours <= 6:
return 300
if hours <= 48:
return 3600
return 86400
def _label_format(hours: int, bucket: int) -> str:
if bucket < 3600:
return "%H:%M"
if bucket == 3600:
return "%H:00" if hours <= 24 else "%m-%d %H:00"
return "%Y-%m-%d"
def _bucket_expr(bucket: int):
# epoch - (epoch % bucket) floors to the bucket start; plain `/` must be
# avoided because SQLAlchemy renders it as true (float) division.
epoch = func.cast(func.strftime("%s", RequestLog.timestamp), Integer)
return epoch - (epoch % bucket)
def _series(f: Filters, db: Session, *aggregates):
"""Group the filtered logs into time buckets and fill gaps with None.
Returns (labels, [values-per-aggregate])."""
bucket = _bucket_seconds(f.hours)
fmt = _label_format(f.hours, bucket)
expr = _bucket_expr(bucket).label("bucket")
rows = (
f.apply(db.query(expr, *aggregates))
.group_by("bucket").order_by("bucket").all()
)
found = {int(r[0]): r for r in rows}
start = int(f.since.timestamp()) // bucket * bucket
now = int(time.time())
labels: list[str] = []
series: list[list] = [[] for _ in aggregates]
for t in range(start, now + 1, bucket):
labels.append(datetime.fromtimestamp(t, timezone.utc).strftime(fmt))
row = found.get(t)
for i in range(len(aggregates)):
series[i].append(row[i + 1] if row else None)
return labels, series
@router.get("/stats/summary")
def summary(f: Filters = Depends(), db: Session = Depends(get_db)):
total = f.apply(db.query(func.count(RequestLog.id))).scalar() or 0
errors = f.apply(db.query(func.count(RequestLog.id))).filter(
RequestLog.status_code >= 500).scalar() or 0
avg_latency = f.apply(db.query(func.avg(RequestLog.latency_ms))).scalar() or 0
return {
"total_requests": total,
"error_count": errors,
"error_rate": round(errors / total * 100, 2) if total else 0,
"avg_latency_ms": round(avg_latency, 1),
"active_services": db.query(Service).filter(Service.is_active).count(),
"active_keys": db.query(ApiKey).filter(ApiKey.is_active).count(),
}
@router.get("/stats/timeseries")
def timeseries(f: Filters = Depends(), db: Session = Depends(get_db)):
labels, (ok, errors) = _series(
f, db,
func.sum(func.iif(RequestLog.status_code < 500, 1, 0)),
func.sum(func.iif(RequestLog.status_code >= 500, 1, 0)),
)
return {
"labels": labels,
"ok": [int(v) if v is not None else 0 for v in ok],
"errors": [int(v) if v is not None else 0 for v in errors],
}
@router.get("/stats/latency-timeseries")
def latency_timeseries(f: Filters = Depends(), db: Session = Depends(get_db)):
labels, (avg_ms,) = _series(f, db, func.avg(RequestLog.latency_ms))
return {
"labels": labels,
"avg_ms": [round(v, 1) if v is not None else None for v in avg_ms],
}
@router.get("/stats/by-service")
def by_service(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(Service.name, func.count(RequestLog.id).label("count"))
.join(RequestLog, RequestLog.service_id == Service.id)
)
rows = (
f.apply(q)
.group_by(Service.id)
.order_by(func.count(RequestLog.id).desc())
.all()
)
return {"labels": [r.name for r in rows], "counts": [r.count for r in rows]}
@router.get("/stats/by-endpoint")
def by_endpoint(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(Service.slug, Endpoint.method, Endpoint.path,
func.count(RequestLog.id).label("count"))
.select_from(RequestLog)
.join(Endpoint, RequestLog.endpoint_id == Endpoint.id)
.join(Service, Endpoint.service_id == Service.id)
)
rows = (
f.apply(q)
.group_by(Endpoint.id)
.order_by(func.count(RequestLog.id).desc())
.limit(15)
.all()
)
return {
"labels": [f"{r.slug}: {r.method} {r.path}" for r in rows],
"counts": [r.count for r in rows],
}
@router.get("/stats/by-status")
def by_status(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(RequestLog.status_code, func.count(RequestLog.id))
)
rows = (
f.apply(q)
.group_by(RequestLog.status_code)
.order_by(RequestLog.status_code)
.all()
)
return {"labels": [str(r[0]) for r in rows], "counts": [r[1] for r in rows]}
@router.get("/stats/latency-by-service")
def latency_by_service(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(Service.name, func.avg(RequestLog.latency_ms).label("avg_ms"))
.join(RequestLog, RequestLog.service_id == Service.id)
)
rows = (
f.apply(q)
.group_by(Service.id)
.order_by(func.avg(RequestLog.latency_ms).desc())
.all()
)
return {"labels": [r.name for r in rows], "avg_ms": [round(r.avg_ms, 1) for r in rows]}
@router.get("/stats/top-keys")
def top_keys(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(ApiKey.name, func.count(RequestLog.id).label("count"))
.join(RequestLog, RequestLog.api_key_id == ApiKey.id)
)
rows = (
f.apply(q, joined_key=True)
.group_by(ApiKey.id)
.order_by(func.count(RequestLog.id).desc())
.limit(10)
.all()
)
return {"labels": [r.name for r in rows], "counts": [r.count for r in rows]}
@router.get("/stats/by-user")
def by_user(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(User.username, func.count(RequestLog.id).label("count"))
.select_from(RequestLog)
.join(ApiKey, RequestLog.api_key_id == ApiKey.id)
.join(User, ApiKey.user_id == User.id)
)
rows = (
f.apply(q, joined_key=True)
.group_by(User.id)
.order_by(func.count(RequestLog.id).desc())
.limit(15)
.all()
)
return {"labels": [r.username for r in rows], "counts": [r.count for r in rows]}
+21
View File
@@ -0,0 +1,21 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DATABASE_URL = os.environ.get("GATEWAY_DATABASE_URL", f"sqlite:///{BASE_DIR / 'gateway.db'}")
SECRET_KEY = os.environ.get("GATEWAY_SECRET_KEY", "dev-secret-change-me")
SESSION_COOKIE = "gw_session"
SESSION_MAX_AGE = 60 * 60 * 8 # 8 hours
# Default admin credentials seeded on first startup (change after first login)
DEFAULT_ADMIN_USERNAME = os.environ.get("GATEWAY_ADMIN_USER", "admin")
DEFAULT_ADMIN_PASSWORD = os.environ.get("GATEWAY_ADMIN_PASSWORD", "admin")
PROXY_DEFAULT_TIMEOUT = 30.0
API_KEY_HEADER = "X-API-Key"
# Retention (0 = keep forever). Payloads are blanked after the first window;
# whole log rows are deleted after the second.
PAYLOAD_RETENTION_DAYS = int(os.environ.get("GATEWAY_PAYLOAD_RETENTION_DAYS", "7"))
LOG_RETENTION_DAYS = int(os.environ.get("GATEWAY_LOG_RETENTION_DAYS", "90"))
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app import config
engine = create_engine(
config.DATABASE_URL,
connect_args={"check_same_thread": False} if config.DATABASE_URL.startswith("sqlite") else {},
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+113
View File
@@ -0,0 +1,113 @@
"""Keep service endpoint catalogs in sync with the upstream's OpenAPI document."""
import time
import httpx
from sqlalchemy.orm import Session
from app.models import Endpoint, Service
METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
SPEC_PATHS = ("/openapi.json", "/swagger.json", "/api-docs")
# Syncing hits the upstream over HTTP, so results are cached per service and
# only refreshed after the TTL (or on demand with force=True).
SYNC_TTL_SECONDS = 300
_last_sync: dict[int, float] = {} # service id -> monotonic time of last attempt
_spec_found: dict[int, bool] = {} # service id -> did the last attempt find a spec
def fetch_spec(base_url: str, verify_tls: bool = True) -> dict | None:
for candidate in SPEC_PATHS:
try:
resp = httpx.get(base_url.rstrip("/") + candidate, timeout=2,
follow_redirects=True, verify=verify_tls)
if resp.status_code == 200:
data = resp.json()
if isinstance(data, dict) and "paths" in data:
return data
except (httpx.HTTPError, ValueError):
continue
return None
def spec_status(service_id: int) -> bool | None:
"""Did the last sync attempt find an OpenAPI document? None = never tried."""
return _spec_found.get(service_id)
def sync_service(db: Session, service: Service, force: bool = False) -> tuple[bool, bool]:
"""Mirror the upstream OpenAPI paths into the endpoint catalog: add new
operations, refresh descriptions, delete operations that disappeared
(their key grants go with them). Services without a reachable spec are
left untouched. Returns (spec_found, catalog_changed); results are cached
between syncs."""
now = time.monotonic()
if not force and now - _last_sync.get(service.id, float("-inf")) < SYNC_TTL_SECONDS:
return _spec_found.get(service.id, False), False
_last_sync[service.id] = now
spec = fetch_spec(service.base_url, service.verify_tls)
_spec_found[service.id] = spec is not None
if spec is None:
return False, False
return True, apply_spec(db, service, spec)
def validate_service(db: Session, service: Service) -> dict:
"""Connectivity probe for the admin UI. Prefers the OpenAPI document —
finding one both proves reachability and refreshes the endpoint cache —
and falls back to a plain GET on the base URL otherwise."""
start = time.perf_counter()
spec = fetch_spec(service.base_url, service.verify_tls)
if spec is not None:
_last_sync[service.id] = time.monotonic()
_spec_found[service.id] = True
apply_spec(db, service, spec)
return {"ok": True, "spec_found": True,
"endpoints": len(service.endpoints),
"latency_ms": round((time.perf_counter() - start) * 1000, 1)}
try:
resp = httpx.get(service.base_url, timeout=5, follow_redirects=True,
verify=service.verify_tls)
return {"ok": True, "spec_found": False, "status_code": resp.status_code,
"endpoints": len(service.endpoints),
"latency_ms": round((time.perf_counter() - start) * 1000, 1)}
except httpx.HTTPError as exc:
return {"ok": False, "spec_found": False,
"error": str(exc) or type(exc).__name__,
"latency_ms": round((time.perf_counter() - start) * 1000, 1)}
def apply_spec(db: Session, service: Service, spec: dict) -> bool:
"""Write an OpenAPI document's paths into the endpoint catalog.
Returns whether anything changed."""
wanted: dict[tuple[str, str], str] = {}
for path, operations in spec["paths"].items():
if not isinstance(operations, dict):
continue
for method, op in operations.items():
method = method.upper()
if method not in METHODS:
continue
summary = op.get("summary", "") if isinstance(op, dict) else ""
wanted[(method, path)] = summary
existing = {(e.method, e.path): e for e in service.endpoints}
changed = False
for (method, path), summary in wanted.items():
endpoint = existing.get((method, path))
if endpoint is None:
db.add(Endpoint(service_id=service.id, method=method, path=path,
description=summary))
changed = True
elif endpoint.description != summary:
endpoint.description = summary
changed = True
for key, endpoint in existing.items():
if key not in wanted:
db.delete(endpoint)
changed = True
db.commit()
db.expire(service, ["endpoints"])
return changed
+133
View File
@@ -0,0 +1,133 @@
import asyncio
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
from sqlalchemy import text
from app import config, proxy, retention, security
from app.admin import routes as admin_routes
from app.admin import stats as admin_stats
from app.admin.deps import LoginRequired, login_redirect_handler
from app.database import Base, SessionLocal, engine
from app.models import User
def enable_incremental_vacuum() -> None:
"""Deleted log rows should hand their pages back to the filesystem;
switching auto_vacuum requires a one-time VACUUM (cannot run in a
transaction, hence the raw connection)."""
raw = engine.raw_connection()
try:
cursor = raw.cursor()
mode = cursor.execute("PRAGMA auto_vacuum").fetchone()[0]
if mode != 2: # 2 = INCREMENTAL
cursor.execute("PRAGMA auto_vacuum=INCREMENTAL")
raw.commit()
cursor.execute("VACUUM")
raw.commit()
finally:
raw.close()
def _table_exists(db, name: str) -> bool:
return db.execute(text(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=:n"
), {"n": name}).first() is not None
def migrate_schema() -> None:
"""Bring older databases up to date (SQLite)."""
with engine.begin() as conn:
cols = [row[1] for row in conn.exec_driver_sql("PRAGMA table_info(request_logs)")]
if cols and "endpoint_id" not in cols:
conn.exec_driver_sql(
"ALTER TABLE request_logs ADD COLUMN endpoint_id INTEGER "
"REFERENCES endpoints(id) ON DELETE SET NULL"
)
for column, ddl in (
("query_string", "VARCHAR(2048) DEFAULT ''"),
("request_body", "TEXT DEFAULT ''"),
("response_body", "TEXT DEFAULT ''"),
):
if cols and column not in cols:
conn.exec_driver_sql(f"ALTER TABLE request_logs ADD COLUMN {column} {ddl}")
service_cols = [row[1] for row in conn.exec_driver_sql("PRAGMA table_info(services)")]
if service_cols and "verify_tls" not in service_cols:
conn.exec_driver_sql("ALTER TABLE services ADD COLUMN verify_tls BOOLEAN DEFAULT 1")
def seed_and_migrate() -> None:
db = SessionLocal()
try:
# Role-era databases: replace role->service grants with per-key grants
# on a service-wide catch-all endpoint, then drop the role tables.
if _table_exists(db, "roles"):
db.execute(text(
"INSERT INTO endpoints (service_id, method, path, description) "
"SELECT id, '*', '/**', 'Migrated: full service access' FROM services"
))
if _table_exists(db, "role_service_access"):
db.execute(text(
"INSERT OR IGNORE INTO key_endpoint_access (api_key_id, endpoint_id) "
"SELECT k.id, e.id "
"FROM api_keys k "
"JOIN users u ON u.id = k.user_id "
"JOIN role_service_access rsa ON rsa.role_id = u.role_id "
"JOIN endpoints e ON e.service_id = rsa.service_id AND e.path = '/**'"
))
db.execute(text("DROP TABLE role_service_access"))
if _table_exists(db, "role_permissions"):
db.execute(text("DROP TABLE role_permissions"))
db.execute(text("ALTER TABLE users DROP COLUMN role_id"))
db.execute(text("DROP TABLE roles"))
db.commit()
print("Migrated role-based access to per-key endpoint grants.")
if db.query(User).count() == 0:
db.add(User(
username=config.DEFAULT_ADMIN_USERNAME,
password_hash=security.hash_password(config.DEFAULT_ADMIN_PASSWORD),
))
db.commit()
print(f"Seeded admin user '{config.DEFAULT_ADMIN_USERNAME}' "
f"(password: '{config.DEFAULT_ADMIN_PASSWORD}' — change it after first login).")
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(engine)
migrate_schema()
seed_and_migrate()
enable_incremental_vacuum()
retention_task = asyncio.create_task(retention.retention_loop())
yield
retention_task.cancel()
with suppress(asyncio.CancelledError):
await retention_task
await proxy.close_client()
app = FastAPI(title="API Gateway", version="2.0.0", lifespan=lifespan)
app.add_exception_handler(LoginRequired, login_redirect_handler)
@app.get("/", include_in_schema=False)
def root():
return RedirectResponse("/admin")
@app.get("/health", include_in_schema=False)
def health():
return {"status": "ok"}
app.include_router(admin_routes.router)
app.include_router(admin_stats.router)
app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static")
# The proxy catch-all (/{slug}/...) must come last so it never shadows
# /admin, /static, /docs or /health.
app.include_router(proxy.router)
+126
View File
@@ -0,0 +1,126 @@
from datetime import datetime, timezone
from sqlalchemy import (
Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Table, Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
key_endpoint_access = Table(
"key_endpoint_access",
Base.metadata,
Column("api_key_id", ForeignKey("api_keys.id", ondelete="CASCADE"), primary_key=True),
Column("endpoint_id", ForeignKey("endpoints.id", ondelete="CASCADE"), primary_key=True),
)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(256))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
api_keys: Mapped[list["ApiKey"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class Service(Base):
__tablename__ = "services"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128))
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
base_url: Mapped[str] = mapped_column(String(512))
description: Mapped[str] = mapped_column(Text, default="")
timeout_seconds: Mapped[float] = mapped_column(Float, default=30.0)
# Disable for upstreams with self-signed / internal-CA certificates.
verify_tls: Mapped[bool] = mapped_column(Boolean, default=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
endpoints: Mapped[list["Endpoint"]] = relationship(
back_populates="service", cascade="all, delete-orphan",
order_by="Endpoint.path",
)
class Endpoint(Base):
"""A callable route of an upstream service.
`method` is an HTTP verb or '*' (any). `path` starts with '/' and may use
'{param}' (one segment), '*' (any within a segment) and '**' (any depth),
e.g. '/orders/{id}', '/reports/**'.
"""
__tablename__ = "endpoints"
id: Mapped[int] = mapped_column(primary_key=True)
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"))
method: Mapped[str] = mapped_column(String(10), default="*")
path: Mapped[str] = mapped_column(String(512))
description: Mapped[str] = mapped_column(Text, default="")
service: Mapped[Service] = relationship(back_populates="endpoints")
api_keys: Mapped[list["ApiKey"]] = relationship(
secondary=key_endpoint_access, back_populates="endpoints"
)
@property
def label(self) -> str:
return f"{self.method} {self.path}"
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(128))
prefix: Mapped[str] = mapped_column(String(16), index=True) # first chars, for display
key_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
rate_limit_per_minute: Mapped[int] = mapped_column(Integer, default=60) # 0 = unlimited
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped[User] = relationship(back_populates="api_keys")
endpoints: Mapped[list[Endpoint]] = relationship(
secondary=key_endpoint_access, back_populates="api_keys"
)
class RequestLog(Base):
__tablename__ = "request_logs"
id: Mapped[int] = mapped_column(primary_key=True)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
api_key_id: Mapped[int | None] = mapped_column(
ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True, index=True
)
service_id: Mapped[int | None] = mapped_column(
ForeignKey("services.id", ondelete="SET NULL"), nullable=True, index=True
)
endpoint_id: Mapped[int | None] = mapped_column(
ForeignKey("endpoints.id", ondelete="SET NULL"), nullable=True, index=True
)
method: Mapped[str] = mapped_column(String(10))
path: Mapped[str] = mapped_column(String(1024))
query_string: Mapped[str] = mapped_column(String(2048), default="")
status_code: Mapped[int] = mapped_column(Integer, index=True)
latency_ms: Mapped[float] = mapped_column(Float)
client_ip: Mapped[str] = mapped_column(String(64), default="")
request_body: Mapped[str] = mapped_column(Text, default="")
response_body: Mapped[str] = mapped_column(Text, default="")
api_key: Mapped[ApiKey | None] = relationship()
service: Mapped[Service | None] = relationship()
endpoint: Mapped[Endpoint | None] = relationship()
+208
View File
@@ -0,0 +1,208 @@
import re
import time
from functools import lru_cache
import httpx
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app import config, ratelimit, security
from app.database import get_db
from app.models import ApiKey, Endpoint, RequestLog, Service, utcnow
router = APIRouter()
# Hop-by-hop headers must not be forwarded either direction
_HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade", "host", "content-length",
}
# One client per TLS-verification mode (verify is a client-level setting in httpx).
_clients: dict[bool, httpx.AsyncClient] = {}
async def get_client(verify_tls: bool = True) -> httpx.AsyncClient:
client = _clients.get(verify_tls)
if client is None:
client = _clients[verify_tls] = httpx.AsyncClient(
follow_redirects=False, verify=verify_tls)
return client
async def close_client() -> None:
for client in _clients.values():
await client.aclose()
_clients.clear()
@lru_cache(maxsize=1024)
def _pattern_to_regex(pattern: str) -> re.Pattern:
"""Endpoint path pattern -> regex. '{param}' = one segment, '*' = any
within a segment, '**' = any depth."""
regex = ""
for token in re.split(r"(\{[^}]*\}|\*\*|\*)", pattern):
if not token:
continue
if token == "**":
regex += ".*"
elif token == "*":
regex += "[^/]*"
elif token.startswith("{") and token.endswith("}"):
regex += "[^/]+"
else:
regex += re.escape(token)
return re.compile("^" + regex + "$")
def match_endpoints(endpoints: list[Endpoint], method: str, path: str) -> list[Endpoint]:
"""All endpoints of a service that match this request, most specific
(exact method) first."""
matches = [
e for e in endpoints
if e.method in ("*", method) and _pattern_to_regex(e.path).match(path)
]
return sorted(matches, key=lambda e: e.method == "*")
def _error(status: int, code: str, message: str) -> JSONResponse:
return JSONResponse(status_code=status, content={"error": code, "message": message})
# Payload capture for transaction inspection
MAX_STORED_BODY = 64 * 1024
_TEXTUAL_TYPES = ("application/json", "application/xml",
"application/x-www-form-urlencoded", "text/", "+json", "+xml")
def _readable(body: bytes, content_type: str) -> str:
"""Body as storable text: textual payloads are kept (truncated at 64 KB),
binary ones are summarized."""
if not body:
return ""
ct = (content_type or "").lower()
if not any(t in ct for t in _TEXTUAL_TYPES):
return f"<{len(body)} bytes of {ct or 'unknown content type'}>"
text = body[:MAX_STORED_BODY].decode("utf-8", "replace")
if len(body) > MAX_STORED_BODY:
text += "\n… (truncated)"
return text
@router.api_route(
"/{slug}/{path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
)
@router.api_route(
"/{slug}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
)
async def gateway(slug: str, request: Request, path: str = "",
db: Session = Depends(get_db)):
start = time.perf_counter()
# --- authenticate ---
plain_key = request.headers.get(config.API_KEY_HEADER)
if not plain_key:
return _error(401, "missing_api_key", f"Provide your API key in the {config.API_KEY_HEADER} header.")
api_key = (
db.query(ApiKey)
.filter(ApiKey.key_hash == security.hash_api_key(plain_key))
.one_or_none()
)
if api_key is None or not api_key.is_active or not api_key.user.is_active:
return _error(403, "invalid_api_key", "API key is unknown or has been revoked.")
# --- resolve service ---
service = db.query(Service).filter(Service.slug == slug).one_or_none()
if service is None or not service.is_active:
return _error(404, "unknown_service", f"No active service registered under '{slug}'.")
# --- read the request payload once (forwarded and logged) ---
body = await request.body()
stored_request = _readable(body, request.headers.get("content-type", ""))
def deny(status: int, code: str, message: str, endpoint: Endpoint | None) -> JSONResponse:
response = _error(status, code, message)
_log(db, api_key, service, endpoint, request, request_path, status, start,
stored_request, response.body.decode())
return response
# --- resolve endpoint & authorize ---
request_path = "/" + path
matched = match_endpoints(service.endpoints, request.method, request_path)
if not matched:
return deny(404, "unknown_endpoint",
f"No endpoint of '{slug}' matches {request.method} {request_path}.", None)
granted_ids = {e.id for e in api_key.endpoints}
endpoint = next((e for e in matched if e.id in granted_ids), None)
if endpoint is None:
return deny(403, "access_denied",
f"This API key has no access to {request.method} {request_path} on '{slug}'.",
matched[0])
# --- rate limit ---
if not ratelimit.check(api_key.id, api_key.rate_limit_per_minute):
return deny(429, "rate_limited",
f"Rate limit of {api_key.rate_limit_per_minute} requests/minute exceeded.",
endpoint)
# --- proxy upstream ---
upstream_url = service.base_url.rstrip("/") + request_path
headers = {
k: v for k, v in request.headers.items()
if k.lower() not in _HOP_BY_HOP and k.lower() != config.API_KEY_HEADER.lower()
}
headers["x-forwarded-for"] = request.client.host if request.client else ""
headers["x-forwarded-proto"] = request.url.scheme
client = await get_client(service.verify_tls)
try:
upstream = await client.request(
request.method,
upstream_url,
params=request.query_params,
content=body,
headers=headers,
timeout=service.timeout_seconds or config.PROXY_DEFAULT_TIMEOUT,
)
except httpx.TimeoutException:
return deny(504, "upstream_timeout", "The upstream API did not respond in time.", endpoint)
except httpx.HTTPError:
return deny(502, "upstream_unreachable", "Could not reach the upstream API.", endpoint)
_log(db, api_key, service, endpoint, request, request_path, upstream.status_code, start,
stored_request, _readable(upstream.content, upstream.headers.get("content-type", "")))
response_headers = {
k: v for k, v in upstream.headers.items() if k.lower() not in _HOP_BY_HOP
}
return Response(
content=upstream.content,
status_code=upstream.status_code,
headers=response_headers,
media_type=upstream.headers.get("content-type"),
)
def _log(db: Session, api_key: ApiKey, service: Service, endpoint: Endpoint | None,
request: Request, request_path: str, status: int, start: float,
request_body: str = "", response_body: str = "") -> None:
api_key.last_used_at = utcnow()
db.add(RequestLog(
api_key_id=api_key.id,
service_id=service.id,
endpoint_id=endpoint.id if endpoint else None,
method=request.method,
path=request_path,
query_string=request.url.query,
status_code=status,
latency_ms=round((time.perf_counter() - start) * 1000, 2),
client_ip=request.client.host if request.client else "",
request_body=request_body,
response_body=response_body,
))
db.commit()
+26
View File
@@ -0,0 +1,26 @@
"""In-memory fixed-window rate limiter, keyed per API key.
Good enough for a single-process gateway; swap for Redis if you scale out.
"""
import time
from collections import defaultdict
from threading import Lock
_windows: dict[int, tuple[int, int]] = defaultdict(lambda: (0, 0)) # key_id -> (window_start, count)
_lock = Lock()
def check(key_id: int, limit_per_minute: int) -> bool:
"""Returns True if the request is allowed."""
if limit_per_minute <= 0:
return True
now = int(time.time() // 60)
with _lock:
window, count = _windows[key_id]
if window != now:
_windows[key_id] = (now, 1)
return True
if count >= limit_per_minute:
return False
_windows[key_id] = (window, count + 1)
return True
+77
View File
@@ -0,0 +1,77 @@
"""Tiered log retention.
Tier 1: request/response payloads are blanked after PAYLOAD_RETENTION_DAYS —
the log row stays inspectable, it just loses the bodies.
Tier 2: whole log rows are deleted after LOG_RETENTION_DAYS.
Both run in batches so a large backlog never locks the database against the
proxy's own log writes, followed by an incremental vacuum to hand freed pages
back to the filesystem. A background loop triggers this every 6 hours.
"""
import asyncio
from datetime import datetime, timedelta, timezone
from sqlalchemy import text
from app import config
from app.database import SessionLocal
PURGE_INTERVAL_SECONDS = 6 * 3600
BATCH_SIZE = 5000
def _cutoff(days: int) -> str:
"""Naive-UTC timestamp string, matching how rows are stored."""
moment = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
return moment.strftime("%Y-%m-%d %H:%M:%S")
def purge_once() -> dict:
stats = {"payloads_blanked": 0, "rows_deleted": 0}
db = SessionLocal()
try:
if config.PAYLOAD_RETENTION_DAYS > 0:
cutoff = _cutoff(config.PAYLOAD_RETENTION_DAYS)
while True:
result = db.execute(text(
"UPDATE request_logs SET request_body = '', response_body = '' "
"WHERE id IN (SELECT id FROM request_logs "
" WHERE timestamp < :cutoff "
" AND (request_body != '' OR response_body != '') "
" LIMIT :batch)"
), {"cutoff": cutoff, "batch": BATCH_SIZE})
db.commit()
stats["payloads_blanked"] += result.rowcount
if result.rowcount < BATCH_SIZE:
break
if config.LOG_RETENTION_DAYS > 0:
cutoff = _cutoff(config.LOG_RETENTION_DAYS)
while True:
result = db.execute(text(
"DELETE FROM request_logs WHERE id IN "
"(SELECT id FROM request_logs WHERE timestamp < :cutoff LIMIT :batch)"
), {"cutoff": cutoff, "batch": BATCH_SIZE})
db.commit()
stats["rows_deleted"] += result.rowcount
if result.rowcount < BATCH_SIZE:
break
if stats["rows_deleted"] or stats["payloads_blanked"]:
db.execute(text("PRAGMA incremental_vacuum"))
db.commit()
finally:
db.close()
return stats
async def retention_loop() -> None:
while True:
try:
stats = await asyncio.to_thread(purge_once)
if stats["payloads_blanked"] or stats["rows_deleted"]:
print(f"Retention: deleted {stats['rows_deleted']} log rows, "
f"blanked {stats['payloads_blanked']} payloads.")
except Exception as exc: # never let a purge failure kill the loop
print(f"Retention run failed: {exc}")
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
+58
View File
@@ -0,0 +1,58 @@
import hashlib
import hmac
import secrets
from itsdangerous import BadSignature, URLSafeTimedSerializer
from app import config
_serializer = URLSafeTimedSerializer(config.SECRET_KEY, salt="gw-session")
_PBKDF2_ITERATIONS = 600_000
# ---- password hashing (PBKDF2-SHA256) ----
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode(), bytes.fromhex(salt), _PBKDF2_ITERATIONS
).hex()
return f"pbkdf2${_PBKDF2_ITERATIONS}${salt}${digest}"
def verify_password(password: str, stored: str) -> bool:
try:
_, iterations, salt, digest = stored.split("$")
computed = hashlib.pbkdf2_hmac(
"sha256", password.encode(), bytes.fromhex(salt), int(iterations)
).hex()
return hmac.compare_digest(computed, digest)
except (ValueError, TypeError):
return False
# ---- API keys ----
def generate_api_key() -> tuple[str, str, str]:
"""Returns (plain_key, prefix, key_hash). Plain key is shown once."""
plain = "gw_" + secrets.token_urlsafe(32)
return plain, plain[:11], hash_api_key(plain)
def hash_api_key(plain: str) -> str:
return hashlib.sha256(plain.encode()).hexdigest()
# ---- session cookies ----
def create_session_token(user_id: int) -> str:
return _serializer.dumps({"uid": user_id})
def read_session_token(token: str) -> int | None:
try:
data = _serializer.loads(token, max_age=config.SESSION_MAX_AGE)
return int(data["uid"])
except (BadSignature, KeyError, ValueError, TypeError):
return None
+218
View File
@@ -0,0 +1,218 @@
/* Dark admin theme — colors from the validated reference palette (dark mode). */
:root {
color-scheme: dark;
--page: #0d0d0d;
--surface: #1a1a19;
--surface-2: #232322;
--ink: #ffffff;
--ink-2: #c3c2b7;
--muted: #898781;
--grid: #2c2c2a;
--border: rgba(255, 255, 255, 0.10);
--series-1: #3987e5; /* blue */
--series-8: #e66767; /* red */
--good: #0ca30c;
--warning: #fab219;
--critical: #d03b3b;
--radius: 10px;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--page); color: var(--ink); font-size: 14px; }
a { color: var(--series-1); text-decoration: none; }
.layout { display: flex; min-height: 100vh; }
/* ---- sidebar ---- */
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface);
border-right: 1px solid var(--border); padding: 20px 12px;
display: flex; flex-direction: column; gap: 4px; position: sticky; top: 0; height: 100vh;
}
.sidebar .brand { font-size: 16px; font-weight: 700; padding: 0 10px 16px; }
.sidebar .brand span { color: var(--series-1); }
.sidebar a.nav-item {
color: var(--ink-2); padding: 9px 10px; border-radius: 8px; display: block;
}
.sidebar a.nav-item:hover { background: var(--surface-2); color: var(--ink); }
.sidebar a.nav-item.active { background: var(--surface-2); color: var(--ink); font-weight: 600; }
.sidebar .spacer { flex: 1; }
.sidebar .whoami { color: var(--muted); font-size: 12px; padding: 0 10px 8px; }
/* ---- main ---- */
.main { flex: 1; padding: 28px 32px; max-width: 1200px; }
h1 { font-size: 20px; margin: 0 0 20px; }
h2 { font-size: 15px; margin: 0 0 12px; color: var(--ink-2); font-weight: 600; }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 18px; margin-bottom: 20px;
}
.grid { display: grid; gap: 16px; }
.grid.cols-2 { grid-template-columns: 1fr 1fr; }
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 900px) { .grid.cols-2, .grid.cols-3 { grid-template-columns: 1fr; } }
/* ---- stat tiles ---- */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 20px; }
.tile { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
.tile .label { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
.tile .value { font-size: 26px; font-weight: 700; }
.tile .value small { font-size: 14px; color: var(--ink-2); font-weight: 400; }
/* ---- tables ---- */
table { width: 100%; border-collapse: collapse; }
th { text-align: left; color: var(--muted); font-size: 12px; font-weight: 600; padding: 8px 10px; border-bottom: 1px solid var(--grid); }
td { padding: 9px 10px; border-bottom: 1px solid var(--grid); color: var(--ink-2); font-variant-numeric: tabular-nums; }
tr:last-child td { border-bottom: none; }
td.strong { color: var(--ink); font-weight: 600; }
code { background: var(--surface-2); padding: 2px 6px; border-radius: 5px; font-size: 12.5px; }
pre {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
padding: 12px; font-size: 12.5px; line-height: 1.5; overflow: auto; max-height: 480px;
white-space: pre-wrap; word-break: break-word; color: var(--ink-2); margin: 0;
}
/* ---- badges ---- */
.badge { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border); }
.badge.on { color: var(--good); }
.badge.off { color: var(--muted); }
.badge.admin { color: var(--series-1); }
.status-2xx { color: var(--good); }
.status-4xx { color: var(--warning); }
.status-5xx { color: var(--critical); }
/* ---- forms ---- */
form.inline { display: inline; }
label { display: block; color: var(--muted); font-size: 12px; margin: 10px 0 4px; }
input[type=text], input[type=password], input[type=url], input[type=number], select, textarea {
width: 100%; background: var(--surface-2); border: 1px solid var(--border);
border-radius: 8px; color: var(--ink); padding: 8px 10px; font: inherit;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--series-1); outline-offset: -1px; }
.checks { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; }
.checks label { display: flex; align-items: center; gap: 6px; margin: 0; color: var(--ink-2); font-size: 13px; }
button, .btn {
background: var(--series-1); color: #fff; border: none; border-radius: 8px;
padding: 8px 14px; font: inherit; font-weight: 600; cursor: pointer;
}
button:hover { filter: brightness(1.1); }
button.ghost { background: transparent; border: 1px solid var(--border); color: var(--ink-2); font-weight: 500; padding: 5px 10px; font-size: 12.5px; }
button.ghost:hover { background: var(--surface-2); color: var(--ink); }
button.danger { background: transparent; border: 1px solid var(--border); color: var(--critical); font-weight: 500; padding: 5px 10px; font-size: 12.5px; }
button.danger:hover { background: rgba(208, 59, 59, 0.12); }
/* ---- misc ---- */
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.right { margin-left: auto; }
.alert { border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; border: 1px solid var(--border); }
.alert.error { color: var(--critical); }
.alert.success { color: var(--good); background: rgba(12, 163, 12, 0.08); }
.alert.success code { user-select: all; }
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
.chart-box { position: relative; height: 260px; }
details.editor { margin-top: 8px; }
details.editor summary { cursor: pointer; color: var(--muted); font-size: 12.5px; }
.range-picker { display: flex; gap: 6px; margin-bottom: 16px; }
.range-picker button.active { background: var(--surface-2); color: var(--ink); }
/* ---- request browser ---- */
tr.req-row { cursor: pointer; }
tr.req-row:hover td { background: var(--surface-2); }
tr.req-row.selected td { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--series-1); }
tr.req-row:focus-visible { outline: 2px solid var(--series-1); outline-offset: -2px; }
/* Split view: list on top, inspector below, each scrolling internally. */
.req-split { display: flex; flex-direction: column; gap: 16px; height: calc(100vh - 130px); }
.req-split .card { margin-bottom: 0; }
.req-list { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
.req-list .table-scroll { flex: 1; min-height: 0; overflow-y: auto; }
.req-list .table-scroll thead th { position: sticky; top: 0; background: var(--surface); z-index: 1; }
#inspector { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
#inspector[hidden] { display: none; }
#inspector .inspector-body { flex: 1; min-height: 0; overflow-y: auto; }
#inspector .inspector-body pre { max-height: none; }
/* ---- on/off switch ---- */
.switch { position: relative; display: inline-block; width: 36px; height: 20px; vertical-align: middle; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch .slider {
position: absolute; inset: 0; cursor: pointer; border-radius: 999px;
background: var(--surface-2); border: 1px solid var(--border); transition: background .15s;
}
.switch .slider::before {
content: ""; position: absolute; width: 14px; height: 14px; border-radius: 50%;
left: 2px; top: 2px; background: var(--muted); transition: transform .15s, background .15s;
}
.switch input:checked + .slider { background: var(--good); border-color: transparent; }
.switch input:checked + .slider::before { transform: translateX(16px); background: #fff; }
.switch input:focus-visible + .slider { outline: 2px solid var(--series-1); }
/* ---- icon buttons ---- */
button.icon {
background: transparent; border: 1px solid var(--border); color: var(--muted);
padding: 4px 7px; line-height: 0; border-radius: 7px;
}
button.icon:hover { color: var(--critical); background: rgba(208, 59, 59, 0.12); }
button.armed {
color: #fff !important; background: var(--critical) !important;
border-color: var(--critical) !important; line-height: 1.2;
font-size: 12px; font-weight: 600;
}
button.icon.edit:hover { color: var(--series-1); background: rgba(57, 135, 229, 0.12); }
/* ---- connectivity validation ---- */
.vresult { font-size: 12px; margin-left: 6px; }
.vresult.ok { color: var(--good); }
.vresult.warn { color: var(--warning); }
.vresult.err { color: var(--critical); }
/* ---- modal overlay ---- */
.modal-overlay {
position: fixed; inset: 0; z-index: 100; display: none;
background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(2px);
align-items: flex-start; justify-content: center; padding: 7vh 20px 40px;
}
.modal-overlay.open { display: flex; }
.modal {
position: relative; width: 100%; max-width: 720px;
max-height: 86vh; overflow-y: auto;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px 22px;
}
.modal > h2 { margin: 0 32px 14px 0; color: var(--ink); }
.modal .modal-close { position: absolute; top: 14px; right: 14px; }
/* ---- spinner ---- */
.spinner, .tree.syncing::after {
display: inline-block; width: 12px; height: 12px; border-radius: 50%;
border: 2px solid var(--grid); border-top-color: var(--series-1);
animation: spin .8s linear infinite; vertical-align: -2px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- endpoint tree picker ---- */
.tree { position: relative; }
.tree.syncing::after { content: ""; position: absolute; top: 4px; right: 4px; }
.tree.syncing { opacity: .75; }
.tree details { margin: 0; }
.tree summary { list-style: none; cursor: pointer; padding: 3px 0; }
.tree summary::-webkit-details-marker { display: none; }
.tree summary::before { content: "▸"; display: inline-block; width: 14px; color: var(--muted); transition: transform .1s; }
.tree details[open] > summary::before { transform: rotate(90deg); }
.tree .tree-children { margin-left: 22px; border-left: 1px solid var(--grid); padding-left: 12px; }
.tree label { display: flex; align-items: center; gap: 7px; margin: 0; padding: 3px 0; color: var(--ink-2); font-size: 13px; cursor: pointer; }
.tree summary label { display: inline-flex; }
.tree .seg { color: var(--ink); font-weight: 600; font-size: 13px; }
.mchip {
display: inline-block; min-width: 42px; text-align: center; font-size: 10.5px; font-weight: 700;
padding: 1px 6px; border-radius: 5px; background: var(--surface-2); color: var(--series-1);
border: 1px solid var(--border); letter-spacing: .3px;
}
.tree .ep-desc { color: var(--muted); font-size: 12px; }
/* ---- login ---- */
.login-wrap { min-height: 100vh; display: grid; place-items: center; }
.login-card { width: 340px; }
+90
View File
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}API Gateway{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="layout">
<nav class="sidebar">
<div class="brand">API <span>Gateway</span></div>
<a class="nav-item {% if active == 'dashboard' %}active{% endif %}" href="/admin">Dashboard</a>
<a class="nav-item {% if active == 'services' %}active{% endif %}" href="/admin/services">Services</a>
<a class="nav-item {% if active == 'users' %}active{% endif %}" href="/admin/users">Users</a>
<a class="nav-item {% if active == 'keys' %}active{% endif %}" href="/admin/keys">API Keys</a>
<a class="nav-item {% if active == 'requests' %}active{% endif %}" href="/admin/requests">Requests</a>
<a class="nav-item {% if active == 'monitoring' %}active{% endif %}" href="/admin/monitoring">Monitoring</a>
<div class="spacer"></div>
{% if user %}
<div class="whoami">{{ user.username }}</div>
<a class="nav-item" href="/admin/logout">Log out</a>
{% endif %}
</nav>
<main class="main">
{% block content %}{% endblock %}
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script>
if (window.Chart) {
Chart.defaults.color = '#898781';
Chart.defaults.borderColor = '#2c2c2a';
Chart.defaults.font.family = 'system-ui, -apple-system, "Segoe UI", sans-serif';
Chart.defaults.plugins.legend.labels.boxWidth = 12;
Chart.defaults.plugins.legend.labels.boxHeight = 12;
Chart.defaults.animation.duration = 300;
}
const GW = {
blue: '#3987e5',
red: '#e66767',
ink2: '#c3c2b7',
grid: '#2c2c2a',
async fetchJSON(url) { const r = await fetch(url); return r.json(); },
};
// Destructive forms carry data-confirm. First click arms the button
// ("Confirm?"), a second click within 5s submits. No native dialogs —
// confirm() is unreliable in embedded browsers.
document.addEventListener('submit', e => {
const form = e.target;
if (!form.dataset.confirm) return;
if (form.dataset.armed === '1') return; // second click: let it through
e.preventDefault();
const btn = form.querySelector('button');
form.dataset.armed = '1';
if (!btn.dataset.orig) btn.dataset.orig = btn.innerHTML;
btn.innerHTML = 'Confirm?';
btn.classList.add('armed');
btn.title = form.dataset.confirm;
setTimeout(() => {
form.dataset.armed = '';
btn.innerHTML = btn.dataset.orig;
btn.classList.remove('armed');
}, 5000);
}, true);
// Modal overlays: [data-modal] opens the overlay with that id; the × button,
// a click on the backdrop, or Escape closes it.
document.addEventListener('click', e => {
const opener = e.target.closest('[data-modal]');
if (opener) {
e.preventDefault();
document.getElementById(opener.dataset.modal).classList.add('open');
return;
}
if (e.target.closest('.modal-close')) {
e.target.closest('.modal-overlay').classList.remove('open');
return;
}
if (e.target.classList.contains('modal-overlay')) e.target.classList.remove('open');
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape')
document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Dashboard · API Gateway{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<div class="tiles" id="tiles">
<div class="tile"><div class="label">Requests (24h)</div><div class="value" id="t-total"></div></div>
<div class="tile"><div class="label">Error rate (24h)</div><div class="value" id="t-errors"></div></div>
<div class="tile"><div class="label">Avg latency (24h)</div><div class="value" id="t-latency"></div></div>
<div class="tile"><div class="label">Active services</div><div class="value" id="t-services"></div></div>
<div class="tile"><div class="label">Active API keys</div><div class="value" id="t-keys"></div></div>
</div>
<div class="card">
<h2>Traffic — last 24 hours</h2>
<div class="chart-box"><canvas id="chart-traffic"></canvas></div>
</div>
<div class="grid cols-2">
<div class="card">
<h2>Requests by service (24h)</h2>
<div class="chart-box"><canvas id="chart-services"></canvas></div>
</div>
<div class="card">
<h2>Recent requests</h2>
<table>
<thead><tr><th>Time (UTC)</th><th>Service</th><th>Path</th><th>Status</th><th>ms</th></tr></thead>
<tbody>
{% for log in recent %}
<tr>
<td>{{ log.timestamp.strftime("%H:%M:%S") }}</td>
<td class="strong">{{ log.service.name if log.service else "" }}</td>
<td>{{ log.method }} {{ log.path[:40] }}</td>
<td class="status-{{ log.status_code // 100 }}xx">{{ log.status_code }}</td>
<td>{{ "%.0f" | format(log.latency_ms) }}</td>
</tr>
{% else %}
<tr><td colspan="5">No traffic yet. Send a request through <code>/gw/&lt;service&gt;/&lt;path&gt;</code>.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(async () => {
const s = await GW.fetchJSON('/admin/api/stats/summary?hours=24');
document.getElementById('t-total').textContent = s.total_requests.toLocaleString();
document.getElementById('t-errors').textContent = s.error_rate + '%';
document.getElementById('t-latency').innerHTML = s.avg_latency_ms + '<small> ms</small>';
document.getElementById('t-services').textContent = s.active_services;
document.getElementById('t-keys').textContent = s.active_keys;
const ts = await GW.fetchJSON('/admin/api/stats/timeseries?hours=24');
new Chart(document.getElementById('chart-traffic'), {
type: 'line',
data: {
labels: ts.labels.map(l => l.slice(11)),
datasets: [
{ label: 'Succeeded', data: ts.ok, borderColor: GW.blue, backgroundColor: GW.blue,
borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3 },
{ label: 'Errors (5xx)', data: ts.errors, borderColor: GW.red, backgroundColor: GW.red,
borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3 },
],
},
options: {
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
y: { beginAtZero: true, ticks: { precision: 0 } },
},
},
});
const bs = await GW.fetchJSON('/admin/api/stats/by-service?hours=24');
new Chart(document.getElementById('chart-services'), {
type: 'bar',
data: {
labels: bs.labels,
datasets: [{ label: 'Requests', data: bs.counts, backgroundColor: GW.blue,
borderRadius: 4, maxBarThickness: 26 }],
},
options: {
maintainAspectRatio: false,
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true, ticks: { precision: 0 } }, y: { grid: { display: false } } },
},
});
})();
</script>
{% endblock %}
+239
View File
@@ -0,0 +1,239 @@
{% extends "base.html" %}
{% block title %}API Keys · API Gateway{% endblock %}
{% macro trash(label) %}
<button class="icon" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
{% endmacro %}
{% macro pencil(label, modal) %}
<button class="icon edit" data-modal="{{ modal }}" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
</button>
{% endmacro %}
{% macro close_button() %}
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{% endmacro %}
{% macro tree_node(node, granted) %}
{% if node.children %}
<details open>
<summary>
<label onclick="event.stopPropagation()">
<input type="checkbox" class="group-box"> <span class="seg">{{ node.name }}</span>
</label>
</summary>
<div class="tree-children">
{% for e in node.endpoints | sort(attribute='method') %}
<label>
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> {{ node.name }}
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
</label>
{% endfor %}
{% for name, child in node.children | dictsort %}
{{ tree_node(child, granted) }}
{% endfor %}
</div>
</details>
{% else %}
{% for e in node.endpoints | sort(attribute='method') %}
<label>
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> {{ node.name }}
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
</label>
{% endfor %}
{% endif %}
{% endmacro %}
{% macro endpoint_picker(services, trees, synced, granted) %}
<div class="tree">
{% for s in services %}
<details open>
<summary>
<label onclick="event.stopPropagation()">
<input type="checkbox" class="group-box">
<span class="seg">{{ s.name }}</span>
<code>/{{ s.slug }}</code>
{% if synced.get(s.id) is sameas false %}<span class="ep-desc">(no OpenAPI document — catalog not auto-synced)</span>{% endif %}
</label>
</summary>
<div class="tree-children">
{% for e in trees[s.id].endpoints | sort(attribute='method') %}
<label>
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> /
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
</label>
{% endfor %}
{% for name, child in trees[s.id].children | dictsort %}
{{ tree_node(child, granted) }}
{% endfor %}
{% if not trees[s.id].children and not trees[s.id].endpoints %}
<span class="hint">No endpoints known for this service.</span>
{% endif %}
</div>
</details>
{% else %}
<span class="hint">Register a service first.</span>
{% endfor %}
</div>
{% endmacro %}
{% block content %}
<div class="row" style="margin-bottom:20px">
<h1 style="margin:0">API Keys &amp; Access</h1>
<button class="right" data-modal="modal-new-key">+ Issue key</button>
</div>
{% if new_key %}
<div class="alert success">
New API key created — copy it now, it will not be shown again:<br>
<code style="font-size:14px">{{ new_key }}</code>
</div>
{% endif %}
<div class="card">
<table>
<thead><tr><th>Name</th><th>Owner</th><th>Key</th><th>Access</th><th>Rate limit</th><th>Last used</th><th>On</th><th></th></tr></thead>
<tbody>
{% for k in keys %}
<tr>
<td class="strong">{{ k.name }}</td>
<td>{{ k.user.username }}</td>
<td><code>{{ k.prefix }}…</code></td>
<td>
{% if k.endpoints %}{{ k.endpoints | length }} endpoint{{ '' if k.endpoints | length == 1 else 's' }}
{% else %}<span class="badge off">none</span>{% endif %}
</td>
<td>{{ k.rate_limit_per_minute if k.rate_limit_per_minute else '∞' }}/min</td>
<td>{{ k.last_used_at.strftime("%Y-%m-%d %H:%M") if k.last_used_at else 'never' }}</td>
<td>
<form class="inline" method="post" action="/admin/keys/{{ k.id }}/toggle">
<label class="switch" title="{{ 'Revoke' if k.is_active else 'Restore' }}">
<input type="checkbox" {% if k.is_active %}checked{% endif %} onchange="this.form.submit()">
<span class="slider"></span>
</label>
</form>
</td>
<td style="white-space:nowrap">
{{ pencil('Edit access for ' ~ k.name, 'modal-key-' ~ k.id) }}
<form class="inline" method="post" action="/admin/keys/{{ k.id }}/delete"
data-confirm="Permanently deletes key {{ k.name }}">
{{ trash('Delete key ' ~ k.name) }}
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8">No API keys yet.</td></tr>
{% endfor %}
</tbody>
</table>
<div class="hint">Endpoint catalogs are mirrored from each service's OpenAPI document
(refreshed at most every 5 minutes; grants on removed endpoints are cleaned up).
<a href="#" id="sync-now">Refresh now</a>
<span id="sync-status"></span></div>
</div>
<div class="card">
<h2>How consumers call the gateway</h2>
<p style="color:var(--ink-2)">Send requests to <code>/&lt;service-slug&gt;/&lt;path&gt;</code> with the header <code>X-API-Key: &lt;key&gt;</code>. The request must match one of the service's endpoints and the key must hold a grant on it.</p>
</div>
{% for k in keys %}
<div class="modal-overlay" id="modal-key-{{ k.id }}" role="dialog" aria-modal="true" aria-label="Edit access for {{ k.name }}">
<div class="modal">
<h2>Access for {{ k.name }} <span class="hint">owned by {{ k.user.username }}</span></h2>
{{ close_button() }}
<form method="post" action="/admin/keys/{{ k.id }}/access">
{% set granted = k.endpoints | map(attribute='id') | list %}
{{ endpoint_picker(services, trees, synced, granted) }}
<div class="row" style="margin-top:14px">
<label style="margin:0">Rate limit/min (0 = unlimited)</label>
<input type="number" name="rate_limit_per_minute" value="{{ k.rate_limit_per_minute }}" min="0" style="max-width:110px">
<button>Save</button>
</div>
</form>
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="modal-new-key" role="dialog" aria-modal="true" aria-label="Issue a new key">
<div class="modal">
<h2>Issue a new key</h2>
{{ close_button() }}
<form method="post" action="/admin/keys">
<div class="grid cols-3">
<div><label>Key name</label><input type="text" name="name" placeholder="mobile-app-prod" required></div>
<div><label>Owner</label>
<select name="user_id">
{% for u in users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
</select>
</div>
<div><label>Rate limit/min (0 = unlimited)</label>
<input type="number" name="rate_limit_per_minute" value="60" min="0"></div>
</div>
<label>Endpoint access</label>
{{ endpoint_picker(services, trees, synced, []) }}
<div style="margin-top:14px"><button>Create key</button></div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// The page renders instantly from the stored catalog; the OpenAPI sync runs
// here in the background. Spinners sit on the endpoint trees while it's
// pending, and the page reloads only if the catalog actually changed.
async function syncCatalogs(force = false) {
const trees = document.querySelectorAll('.tree');
const status = document.getElementById('sync-status');
trees.forEach(t => t.classList.add('syncing'));
status.innerHTML = '<span class="spinner"></span> refreshing catalogs…';
try {
const r = await GW.fetchJSON('/admin/api/endpoints/sync' + (force ? '?force=1' : ''));
if (r.changed) { location.reload(); return; }
status.textContent = 'up to date';
} catch {
status.textContent = 'refresh failed';
} finally {
trees.forEach(t => t.classList.remove('syncing'));
}
}
syncCatalogs();
document.getElementById('sync-now').addEventListener('click', e => {
e.preventDefault();
syncCatalogs(true);
});
// Hierarchical picker: a group checkbox selects everything beneath it;
// leaf changes roll their state back up (checked / indeterminate).
function refreshGroups(scope) {
[...scope.querySelectorAll('.tree details')].reverse().forEach(d => {
const group = d.querySelector(':scope > summary .group-box');
const leaves = d.querySelectorAll('input[name=endpoint_ids]');
if (!group || !leaves.length) return;
const checked = [...leaves].filter(b => b.checked).length;
group.checked = checked === leaves.length;
group.indeterminate = checked > 0 && checked < leaves.length;
});
}
document.querySelectorAll('.tree').forEach(tree => {
tree.addEventListener('change', e => {
if (e.target.classList.contains('group-box')) {
const details = e.target.closest('details');
details.querySelectorAll('input[name=endpoint_ids]').forEach(b => { b.checked = e.target.checked; });
}
refreshGroups(tree);
});
refreshGroups(tree);
});
</script>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · API Gateway</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="login-wrap">
<div class="card login-card">
<div class="brand" style="font-size:18px;font-weight:700;margin-bottom:14px;">
API <span style="color:var(--series-1)">Gateway</span>
</div>
{% if error %}<div class="alert error">{{ error }}</div>{% endif %}
<form method="post" action="/admin/login">
<label for="username">Username</label>
<input id="username" type="text" name="username" required autofocus>
<label for="password">Password</label>
<input id="password" type="password" name="password" required>
<div style="margin-top:16px"><button type="submit" style="width:100%">Sign in</button></div>
</form>
</div>
</div>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
{% extends "base.html" %}
{% block title %}Monitoring · API Gateway{% endblock %}
{% block content %}
<h1>Usage Monitoring</h1>
<div class="row" style="margin-bottom:16px">
<div class="range-picker" role="group" aria-label="Time range" style="margin:0">
<button class="ghost" data-hours="1">1 h</button>
<button class="ghost" data-hours="6">6 h</button>
<button class="ghost" data-hours="24">24 h</button>
<button class="ghost" data-hours="72">3 days</button>
<button class="ghost" data-hours="168">7 days</button>
<button class="ghost" data-hours="720">30 days</button>
</div>
<select id="f-service" class="right" style="max-width:170px">
<option value="">All services</option>
{% for s in services %}<option value="{{ s.id }}">{{ s.name }}</option>{% endfor %}
</select>
<select id="f-user" style="max-width:150px">
<option value="">All users</option>
{% for u in users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
</select>
<select id="f-key" style="max-width:150px">
<option value="">All keys</option>
{% for k in keys %}<option value="{{ k.id }}">{{ k.name }}</option>{% endfor %}
</select>
</div>
<div class="tiles">
<div class="tile"><div class="label">Requests</div><div class="value" id="t-total"></div></div>
<div class="tile"><div class="label">Errors (5xx)</div><div class="value" id="t-errors"></div></div>
<div class="tile"><div class="label">Error rate</div><div class="value" id="t-rate"></div></div>
<div class="tile"><div class="label">Avg latency</div><div class="value" id="t-latency"></div></div>
</div>
<div class="grid cols-2">
<div class="card">
<h2>Requests over time</h2>
<div class="chart-box"><canvas id="chart-traffic"></canvas></div>
</div>
<div class="card">
<h2>Average latency over time (ms)</h2>
<div class="chart-box"><canvas id="chart-latency-ts"></canvas></div>
</div>
<div class="card">
<h2>Requests by service</h2>
<div class="chart-box"><canvas id="chart-services"></canvas></div>
</div>
<div class="card">
<h2>Responses by status code</h2>
<div class="chart-box"><canvas id="chart-status"></canvas></div>
</div>
<div class="card">
<h2>Requests by user</h2>
<div class="chart-box"><canvas id="chart-users"></canvas></div>
</div>
<div class="card">
<h2>Requests by endpoint</h2>
<div class="chart-box"><canvas id="chart-endpoints"></canvas></div>
</div>
<div class="card">
<h2>Average latency by service (ms)</h2>
<div class="chart-box"><canvas id="chart-latency"></canvas></div>
</div>
<div class="card">
<h2>Top API keys by usage</h2>
<div class="chart-box"><canvas id="chart-keys"></canvas></div>
</div>
</div>
<div class="card">
<h2>Latest 100 requests</h2>
<table>
<thead><tr><th>Time (UTC)</th><th>Service</th><th>Key</th><th>User</th><th>Endpoint</th><th>Request</th><th>Status</th><th>Latency</th><th>Client IP</th></tr></thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}</td>
<td class="strong">{{ log.service.name if log.service else "" }}</td>
<td>{{ log.api_key.name if log.api_key else "" }}</td>
<td>{{ log.api_key.user.username if log.api_key else "" }}</td>
<td>{% if log.endpoint %}<code>{{ log.endpoint.path }}</code>{% else %}{% endif %}</td>
<td><a href="/admin/requests?inspect={{ log.id }}">{{ log.method }} {{ log.path[:50] }}</a></td>
<td class="status-{{ log.status_code // 100 }}xx">{{ log.status_code }}</td>
<td>{{ "%.1f" | format(log.latency_ms) }} ms</td>
<td>{{ log.client_ip }}</td>
</tr>
{% else %}
<tr><td colspan="9">No requests logged yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% block scripts %}
<script>
const charts = {};
let hours = 24;
function qs() {
const p = new URLSearchParams({ hours });
for (const [param, id] of [['service_id', 'f-service'], ['user_id', 'f-user'], ['key_id', 'f-key']]) {
const v = document.getElementById(id).value;
if (v) p.set(param, v);
}
return p.toString();
}
function barChart(id, labels, data, horizontal = false) {
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(document.getElementById(id), {
type: 'bar',
data: { labels, datasets: [{ data, backgroundColor: GW.blue, borderRadius: 4, maxBarThickness: 26 }] },
options: {
maintainAspectRatio: false,
indexAxis: horizontal ? 'y' : 'x',
plugins: { legend: { display: false } },
scales: horizontal
? { x: { beginAtZero: true }, y: { grid: { display: false } } }
: { x: { grid: { display: false } }, y: { beginAtZero: true, ticks: { precision: 0 } } },
},
});
}
function lineChart(id, labels, datasets, showLegend) {
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(document.getElementById(id), {
type: 'line',
data: { labels, datasets },
options: {
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { display: showLegend } },
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 14 } },
y: { beginAtZero: true },
},
},
});
}
const lineStyle = { borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3, spanGaps: true };
async function load() {
const query = qs();
const [sum, ts, lts, bs, st, lat, keys, byUser, byEndpoint] = await Promise.all([
'summary', 'timeseries', 'latency-timeseries', 'by-service', 'by-status',
'latency-by-service', 'top-keys', 'by-user', 'by-endpoint',
].map(ep => GW.fetchJSON(`/admin/api/stats/${ep}?${query}`)));
document.getElementById('t-total').textContent = sum.total_requests.toLocaleString();
document.getElementById('t-errors').textContent = sum.error_count.toLocaleString();
document.getElementById('t-rate').textContent = sum.error_rate + '%';
document.getElementById('t-latency').innerHTML = sum.avg_latency_ms + '<small> ms</small>';
lineChart('chart-traffic', ts.labels, [
{ label: 'Succeeded', data: ts.ok, borderColor: GW.blue, backgroundColor: GW.blue, ...lineStyle },
{ label: 'Errors (5xx)', data: ts.errors, borderColor: GW.red, backgroundColor: GW.red, ...lineStyle },
], true);
lineChart('chart-latency-ts', lts.labels, [
{ label: 'Avg latency (ms)', data: lts.avg_ms, borderColor: GW.blue, backgroundColor: GW.blue, ...lineStyle },
], false);
barChart('chart-services', bs.labels, bs.counts, true);
barChart('chart-status', st.labels, st.counts);
barChart('chart-users', byUser.labels, byUser.counts, true);
barChart('chart-endpoints', byEndpoint.labels, byEndpoint.counts, true);
barChart('chart-latency', lat.labels, lat.avg_ms, true);
barChart('chart-keys', keys.labels, keys.counts, true);
}
document.querySelectorAll('.range-picker button').forEach(b =>
b.addEventListener('click', () => {
hours = +b.dataset.hours;
document.querySelectorAll('.range-picker button').forEach(x =>
x.classList.toggle('active', x === b));
load();
}));
['f-service', 'f-user', 'f-key'].forEach(id =>
document.getElementById(id).addEventListener('change', load));
document.querySelector('.range-picker button[data-hours="24"]').classList.add('active');
load();
</script>
{% endblock %}
+173
View File
@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block title %}Requests · API Gateway{% endblock %}
{% block content %}
<h1>Requests</h1>
<div class="req-split">
<div class="card">
<form method="get" action="/admin/requests" class="row" id="filter-form">
<select name="service_id" style="max-width:160px">
<option value="">All services</option>
{% for s in services %}<option value="{{ s.id }}" {% if f.service_id == s.id %}selected{% endif %}>{{ s.name }}</option>{% endfor %}
</select>
<select name="user_id" style="max-width:140px">
<option value="">All users</option>
{% for u in users %}<option value="{{ u.id }}" {% if f.user_id == u.id %}selected{% endif %}>{{ u.username }}</option>{% endfor %}
</select>
<select name="key_id" style="max-width:150px">
<option value="">All keys</option>
{% for k in keys %}<option value="{{ k.id }}" {% if f.key_id == k.id %}selected{% endif %}>{{ k.name }}</option>{% endfor %}
</select>
<select name="status_class" style="max-width:120px">
<option value="">All statuses</option>
{% for c in ['2', '3', '4', '5'] %}
<option value="{{ c }}" {% if f.status_class == c %}selected{% endif %}>{{ c }}xx</option>
{% endfor %}
</select>
<input type="text" name="q" value="{{ f.q }}" placeholder="Path contains…" style="max-width:200px">
<span class="right hint">{{ total }} request{{ '' if total == 1 else 's' }}</span>
</form>
</div>
<div class="card req-list">
<div class="table-scroll">
<table id="req-table">
<thead><tr><th>Time (UTC)</th><th>Service</th><th>Key</th><th>Request</th><th>Status</th><th>Latency</th></tr></thead>
<tbody>
{% for log in logs %}
<tr class="req-row" data-id="{{ log.id }}" tabindex="0" role="button"
aria-label="Inspect request {{ log.id }}">
<td>{{ log.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}</td>
<td class="strong">{{ log.service.name if log.service else "" }}</td>
<td>{{ log.api_key.name if log.api_key else "" }}</td>
<td>{{ log.method }} {{ log.path[:60] }}{% if log.query_string %}?{{ log.query_string[:30] }}{% endif %}</td>
<td class="status-{{ log.status_code // 100 }}xx">{{ log.status_code }}</td>
<td>{{ "%.1f" | format(log.latency_ms) }} ms</td>
</tr>
{% else %}
<tr><td colspan="6">No requests match these filters.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
{% set base = '/admin/requests?service_id=' ~ (f.service_id or '') ~ '&user_id=' ~ (f.user_id or '') ~ '&key_id=' ~ (f.key_id or '') ~ '&status_class=' ~ f.status_class ~ '&q=' ~ f.q %}
<div class="row" style="margin-top:12px">
{% if page > 1 %}<a class="btn ghost" href="{{ base }}&page={{ page - 1 }}" style="padding:5px 10px">← Newer</a>{% endif %}
<span class="hint">Page {{ page }} of {{ pages }}</span>
{% if page < pages %}<a class="btn ghost" href="{{ base }}&page={{ page + 1 }}" style="padding:5px 10px">Older →</a>{% endif %}
</div>
{% endif %}
</div>
<div class="card" id="inspector" hidden>
<div class="row" style="margin-bottom:12px">
<h2 style="margin:0">Request <span id="i-id"></span></h2>
<span id="i-status" class="badge"></span>
<span class="hint" id="i-latency"></span>
<span class="hint" id="i-time"></span>
<button type="button" class="icon right" id="i-close" title="Close inspector" aria-label="Close inspector">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="inspector-body">
<table style="margin-bottom:16px">
<tbody>
<tr><td class="strong" style="width:160px">Gateway request</td><td><code id="i-gwreq"></code></td></tr>
<tr><td class="strong">Forwarded to</td><td><code id="i-fwd"></code></td></tr>
<tr><td class="strong">Service</td><td id="i-service"></td></tr>
<tr><td class="strong">Matched endpoint</td><td id="i-endpoint"></td></tr>
<tr><td class="strong">API key</td><td id="i-key"></td></tr>
<tr><td class="strong">User</td><td id="i-user"></td></tr>
<tr><td class="strong">Client IP</td><td id="i-ip"></td></tr>
</tbody>
</table>
<div class="grid cols-2">
<div>
<h2>Request payload</h2>
<pre id="i-reqbody"></pre>
</div>
<div>
<h2>Response payload</h2>
<pre id="i-resbody"></pre>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const inspector = document.getElementById('inspector');
const $ = id => document.getElementById(id);
async function inspect(id) {
const r = await fetch(`/admin/requests/${id}/data`);
if (!r.ok) return;
const d = await r.json();
$('i-id').textContent = '#' + d.id;
$('i-status').textContent = d.status;
$('i-status').className = `badge status-${Math.floor(d.status / 100)}xx`;
$('i-latency').textContent = d.latency_ms + ' ms';
$('i-time').textContent = d.time + ' UTC';
const qs = d.query_string ? '?' + d.query_string : '';
$('i-gwreq').textContent = `${d.method} /${d.slug ?? '?'}${d.path}${qs}`;
$('i-fwd').textContent = d.forwarded_to ?? '';
$('i-service').textContent = d.service ?? '';
$('i-endpoint').textContent = d.endpoint
? d.endpoint + (d.endpoint_description ? ' — ' + d.endpoint_description : '') : '';
$('i-key').textContent = d.key ? `${d.key} (${d.key_prefix}…)` : '';
$('i-user').textContent = d.user ?? '';
$('i-ip').textContent = d.client_ip || '';
$('i-reqbody').textContent = d.request_body || 'No request body.';
$('i-resbody').textContent = d.response_body || 'No response body captured.';
document.querySelectorAll('.req-row').forEach(row =>
row.classList.toggle('selected', +row.dataset.id === d.id));
inspector.hidden = false;
inspector.querySelector('.inspector-body').scrollTop = 0;
const url = new URL(location);
url.searchParams.set('inspect', d.id);
history.replaceState(null, '', url);
}
document.querySelectorAll('.req-row').forEach(row => {
row.addEventListener('click', () => inspect(+row.dataset.id));
row.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); inspect(+row.dataset.id); }
});
});
document.getElementById('i-close').addEventListener('click', () => {
inspector.hidden = true;
document.querySelectorAll('.req-row.selected').forEach(r => r.classList.remove('selected'));
const url = new URL(location);
url.searchParams.delete('inspect');
history.replaceState(null, '', url);
});
const preselected = new URLSearchParams(location.search).get('inspect');
if (preselected) inspect(+preselected);
// Filters apply immediately; empty fields are omitted from the URL.
const filterForm = document.getElementById('filter-form');
function applyFilters() {
const p = new URLSearchParams();
for (const el of filterForm.elements) {
if (el.name && el.value) p.set(el.name, el.value);
}
location.href = '/admin/requests' + (p.size ? '?' + p.toString() : '');
}
filterForm.addEventListener('submit', e => { e.preventDefault(); applyFilters(); });
filterForm.querySelectorAll('select').forEach(s => s.addEventListener('change', applyFilters));
let debounce;
filterForm.querySelector('input[name=q]').addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(applyFilters, 500);
});
</script>
{% endblock %}
+147
View File
@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}Services · API Gateway{% endblock %}
{% macro trash(label) %}
<button class="icon" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
{% endmacro %}
{% macro pencil(label, modal) %}
<button class="icon edit" data-modal="{{ modal }}" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
</button>
{% endmacro %}
{% block content %}
<div class="row" style="margin-bottom:20px">
<h1 style="margin:0">Connected APIs</h1>
<button class="right" data-modal="modal-new-service">+ Add service</button>
</div>
<div class="card">
<table>
<thead>
<tr><th>Name</th><th>Gateway route</th><th>Upstream</th><th>Timeout</th><th>Requests</th><th>Connectivity</th><th>On</th><th></th></tr>
</thead>
<tbody>
{% for s in services %}
<tr>
<td class="strong">{{ s.name }}</td>
<td><code>/{{ s.slug }}/…</code></td>
<td>{{ s.base_url }}</td>
<td>{{ s.timeout_seconds }}s</td>
<td>{{ counts.get(s.id, 0) }}</td>
<td style="white-space:nowrap">
<button type="button" class="ghost validate-btn" data-id="{{ s.id }}">Validate</button>
<span class="vresult" id="vresult-{{ s.id }}"></span>
</td>
<td>
<form class="inline" method="post" action="/admin/services/{{ s.id }}/toggle">
<label class="switch" title="{{ 'Turn off' if s.is_active else 'Turn on' }}">
<input type="checkbox" {% if s.is_active %}checked{% endif %} onchange="this.form.submit()">
<span class="slider"></span>
</label>
</form>
</td>
<td style="white-space:nowrap">
{{ pencil('Edit ' ~ s.name, 'modal-service-' ~ s.id) }}
<form class="inline" method="post" action="/admin/services/{{ s.id }}/delete"
data-confirm="Deletes {{ s.name }} and its endpoints">
{{ trash('Delete ' ~ s.name) }}
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8">No services registered yet.</td></tr>
{% endfor %}
</tbody>
</table>
<div class="hint">Endpoints are managed on the <a href="/admin/keys">API Keys</a> page,
where each service's catalog is kept in sync with its OpenAPI document.</div>
</div>
{% for s in services %}
<div class="modal-overlay" id="modal-service-{{ s.id }}" role="dialog" aria-modal="true" aria-label="Edit {{ s.name }}">
<div class="modal">
<h2>Edit {{ s.name }}</h2>
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<form method="post" action="/admin/services/{{ s.id }}/update" class="grid cols-2">
<div><label>Name</label><input type="text" name="name" value="{{ s.name }}" required></div>
<div><label>Base URL</label><input type="url" name="base_url" value="{{ s.base_url }}" required></div>
<div><label>Timeout (seconds)</label><input type="number" name="timeout_seconds" value="{{ s.timeout_seconds }}" step="0.5" min="1"></div>
<div><label>Description</label><input type="text" name="description" value="{{ s.description }}"></div>
<div style="grid-column:1/-1" class="checks">
<label><input type="checkbox" name="verify_tls" {% if s.verify_tls %}checked{% endif %}>
Verify TLS certificate <span class="hint" style="margin:0">(untick for self-signed / internal-CA upstreams)</span></label>
</div>
<div><button>Save changes</button></div>
</form>
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="modal-new-service" role="dialog" aria-modal="true" aria-label="Register a new API">
<div class="modal">
<h2>Register a new API</h2>
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<form method="post" action="/admin/services" class="grid cols-2">
<div><label>Name</label><input type="text" name="name" placeholder="Weather API" required></div>
<div><label>Slug (route segment)</label><input type="text" name="slug" placeholder="weather" pattern="[a-z0-9\-]+" required>
<div class="hint">Consumers will call <code>/&lt;slug&gt;/…</code></div></div>
<div><label>Base URL</label><input type="url" name="base_url" placeholder="https://api.example.com/v1" required></div>
<div><label>Timeout (seconds)</label><input type="number" name="timeout_seconds" value="30" step="0.5" min="1"></div>
<div style="grid-column:1/-1"><label>Description</label><input type="text" name="description" placeholder="Optional"></div>
<div style="grid-column:1/-1" class="checks">
<label><input type="checkbox" name="verify_tls" checked>
Verify TLS certificate <span class="hint" style="margin:0">(untick for self-signed / internal-CA upstreams)</span></label>
</div>
<div><button>Add service</button></div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Connectivity probe. Finding an OpenAPI document also refreshes the
// endpoint cache, so a validation doubles as an endpoint import.
async function validateService(id) {
const out = document.getElementById('vresult-' + id);
if (!out) return;
out.className = 'vresult';
out.innerHTML = '<span class="spinner"></span>';
try {
const resp = await fetch(`/admin/services/${id}/validate`, { method: 'POST' });
const d = await resp.json();
if (d.ok && d.spec_found) {
out.className = 'vresult ok';
out.textContent = `✓ reachable · ${d.endpoints} endpoints cached · ${d.latency_ms} ms`;
} else if (d.ok) {
out.className = 'vresult warn';
out.textContent = `✓ reachable (HTTP ${d.status_code}) · no OpenAPI document · ${d.latency_ms} ms`;
} else {
out.className = 'vresult err';
out.textContent = `✗ unreachable — ${d.error}`;
}
} catch {
out.className = 'vresult err';
out.textContent = '✗ validation failed';
}
}
document.querySelectorAll('.validate-btn').forEach(btn =>
btn.addEventListener('click', () => validateService(btn.dataset.id)));
// A freshly registered service is validated automatically.
const pending = new URLSearchParams(location.search).get('validate');
if (pending) {
validateService(pending);
history.replaceState(null, '', '/admin/services');
}
</script>
{% endblock %}
+91
View File
@@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block title %}Users · API Gateway{% endblock %}
{% macro trash(label) %}
<button class="icon" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
{% endmacro %}
{% macro pencil(label, modal) %}
<button class="icon edit" data-modal="{{ modal }}" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
</button>
{% endmacro %}
{% block content %}
<div class="row" style="margin-bottom:20px">
<h1 style="margin:0">Users</h1>
<button class="right" data-modal="modal-new-user">+ Add user</button>
</div>
<div class="card">
<table>
<thead><tr><th>Username</th><th>API keys</th><th>Created</th><th>On</th><th></th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td class="strong">{{ u.username }}</td>
<td>{{ u.api_keys | length }}</td>
<td>{{ u.created_at.strftime("%Y-%m-%d") }}</td>
<td>
{% if u.id != user.id %}
<form class="inline" method="post" action="/admin/users/{{ u.id }}/toggle">
<label class="switch" title="{{ 'Disable' if u.is_active else 'Enable' }}">
<input type="checkbox" {% if u.is_active %}checked{% endif %} onchange="this.form.submit()">
<span class="slider"></span>
</label>
</form>
{% else %}
<label class="switch" title="You cannot deactivate your own account">
<input type="checkbox" checked disabled><span class="slider" style="opacity:.5"></span>
</label>
{% endif %}
</td>
<td style="white-space:nowrap">
{{ pencil('Edit ' ~ u.username, 'modal-user-' ~ u.id) }}
{% if u.id != user.id %}
<form class="inline" method="post" action="/admin/users/{{ u.id }}/delete"
data-confirm="Deletes {{ u.username }} and all their API keys">
{{ trash('Delete ' ~ u.username) }}
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="hint">Every user can sign in to this console and own API keys.
Gateway access itself is granted per key on the <a href="/admin/keys">API Keys</a> page.</div>
</div>
{% for u in users %}
<div class="modal-overlay" id="modal-user-{{ u.id }}" role="dialog" aria-modal="true" aria-label="Edit {{ u.username }}">
<div class="modal">
<h2>Edit {{ u.username }}</h2>
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<form method="post" action="/admin/users/{{ u.id }}/password">
<label>New password</label>
<input type="password" name="password" required>
<div style="margin-top:14px"><button>Reset password</button></div>
</form>
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="modal-new-user" role="dialog" aria-modal="true" aria-label="Add a user">
<div class="modal">
<h2>Add a user</h2>
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<form method="post" action="/admin/users" class="grid cols-2">
<div><label>Username</label><input type="text" name="username" required></div>
<div><label>Password</label><input type="password" name="password" required></div>
<div><button>Create user</button></div>
</form>
</div>
</div>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
services:
gateway:
build: .
ports:
- "8000:8000"
environment:
# Set a strong random value in production — signs the admin session cookies.
GATEWAY_SECRET_KEY: change-me-in-production
GATEWAY_ADMIN_USER: admin
GATEWAY_ADMIN_PASSWORD: admin
# Retention (days; 0 = keep forever)
GATEWAY_PAYLOAD_RETENTION_DAYS: "7"
GATEWAY_LOG_RETENTION_DAYS: "90"
volumes:
- gateway-data:/data
restart: unless-stopped
volumes:
gateway-data:
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.115
uvicorn[standard]>=0.32
httpx>=0.28
SQLAlchemy>=2.0.36
jinja2>=3.1
python-multipart>=0.0.12
itsdangerous>=2.2