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:
@@ -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
|
||||
Reference in New Issue
Block a user