Add per-key OpenAPI document and Swagger UI portal
GET /openapi.json (X-API-Key authenticated) merges the upstream OpenAPI documents into one spec scoped to the calling key: only granted operations, paths rewritten to gateway routes, component schemas namespaced per service. GET /docs serves a Swagger UI portal that loads the key-scoped spec and injects the key into try-it-out requests. Discovery now caches the raw upstream spec documents (same 5-minute TTL), and FastAPI's built-in /docs and /openapi.json are disabled in favor of the portal routes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
77d7a50fa9
commit
d5878f6130
@@ -33,6 +33,11 @@ with a built-in web management console.
|
|||||||
codes, latency by service, top keys — filterable by time range (1 h – 30 d),
|
codes, latency by service, top keys — filterable by time range (1 h – 30 d),
|
||||||
service, user and key.
|
service, user and key.
|
||||||
|
|
||||||
|
- **Consumer docs** — `GET /openapi.json` (authenticated with `X-API-Key`)
|
||||||
|
returns a merged OpenAPI document scoped to that key: every operation the
|
||||||
|
key holds a grant on, across all services, rewritten to the gateway's
|
||||||
|
routes. `GET /docs` serves a Swagger UI portal around it — paste a key,
|
||||||
|
browse and try exactly what that key can call.
|
||||||
- **Retention** — a background purge runs every 6 hours: request/response
|
- **Retention** — a background purge runs every 6 hours: request/response
|
||||||
payloads are blanked after 7 days (the log row stays inspectable), whole log
|
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
|
rows are deleted after 90 days, and freed pages are returned to the
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ SPEC_PATHS = ("/openapi.json", "/swagger.json", "/api-docs")
|
|||||||
SYNC_TTL_SECONDS = 300
|
SYNC_TTL_SECONDS = 300
|
||||||
_last_sync: dict[int, float] = {} # service id -> monotonic time of last attempt
|
_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
|
_spec_found: dict[int, bool] = {} # service id -> did the last attempt find a spec
|
||||||
|
_spec_cache: dict[int, dict] = {} # service id -> raw OpenAPI document of last fetch
|
||||||
|
|
||||||
|
|
||||||
def fetch_spec(base_url: str, verify_tls: bool = True) -> dict | None:
|
def fetch_spec(base_url: str, verify_tls: bool = True) -> dict | None:
|
||||||
@@ -51,9 +52,18 @@ def sync_service(db: Session, service: Service, force: bool = False) -> tuple[bo
|
|||||||
_spec_found[service.id] = spec is not None
|
_spec_found[service.id] = spec is not None
|
||||||
if spec is None:
|
if spec is None:
|
||||||
return False, False
|
return False, False
|
||||||
|
_spec_cache[service.id] = spec
|
||||||
return True, apply_spec(db, service, spec)
|
return True, apply_spec(db, service, spec)
|
||||||
|
|
||||||
|
|
||||||
|
def get_spec(db: Session, service: Service) -> dict | None:
|
||||||
|
"""Raw upstream OpenAPI document, refreshed through the same TTL cache as
|
||||||
|
the endpoint sync. Falls back to the last known document if the upstream
|
||||||
|
is temporarily unreachable."""
|
||||||
|
sync_service(db, service)
|
||||||
|
return _spec_cache.get(service.id)
|
||||||
|
|
||||||
|
|
||||||
def validate_service(db: Session, service: Service) -> dict:
|
def validate_service(db: Session, service: Service) -> dict:
|
||||||
"""Connectivity probe for the admin UI. Prefers the OpenAPI document —
|
"""Connectivity probe for the admin UI. Prefers the OpenAPI document —
|
||||||
finding one both proves reachability and refreshes the endpoint cache —
|
finding one both proves reachability and refreshes the endpoint cache —
|
||||||
@@ -63,6 +73,7 @@ def validate_service(db: Session, service: Service) -> dict:
|
|||||||
if spec is not None:
|
if spec is not None:
|
||||||
_last_sync[service.id] = time.monotonic()
|
_last_sync[service.id] = time.monotonic()
|
||||||
_spec_found[service.id] = True
|
_spec_found[service.id] = True
|
||||||
|
_spec_cache[service.id] = spec
|
||||||
apply_spec(db, service, spec)
|
apply_spec(db, service, spec)
|
||||||
return {"ok": True, "spec_found": True,
|
return {"ok": True, "spec_found": True,
|
||||||
"endpoints": len(service.endpoints),
|
"endpoints": len(service.endpoints),
|
||||||
|
|||||||
+6
-2
@@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app import config, proxy, retention, security
|
from app import config, portal, proxy, retention, security
|
||||||
from app.admin import routes as admin_routes
|
from app.admin import routes as admin_routes
|
||||||
from app.admin import stats as admin_stats
|
from app.admin import stats as admin_stats
|
||||||
from app.admin.deps import LoginRequired, login_redirect_handler
|
from app.admin.deps import LoginRequired, login_redirect_handler
|
||||||
@@ -111,7 +111,10 @@ async def lifespan(app: FastAPI):
|
|||||||
await proxy.close_client()
|
await proxy.close_client()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="API Gateway", version="2.0.0", lifespan=lifespan)
|
# Built-in docs/openapi are disabled: the gateway serves its own consumer-facing
|
||||||
|
# /docs and per-key /openapi.json (app/portal.py) at those paths instead.
|
||||||
|
app = FastAPI(title="API Gateway", version="2.0.0", lifespan=lifespan,
|
||||||
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
app.add_exception_handler(LoginRequired, login_redirect_handler)
|
app.add_exception_handler(LoginRequired, login_redirect_handler)
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +130,7 @@ def health():
|
|||||||
|
|
||||||
app.include_router(admin_routes.router)
|
app.include_router(admin_routes.router)
|
||||||
app.include_router(admin_stats.router)
|
app.include_router(admin_stats.router)
|
||||||
|
app.include_router(portal.router)
|
||||||
app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static")
|
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
|
# The proxy catch-all (/{slug}/...) must come last so it never shadows
|
||||||
# /admin, /static, /docs or /health.
|
# /admin, /static, /docs or /health.
|
||||||
|
|||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
"""Consumer-facing API documentation.
|
||||||
|
|
||||||
|
`GET /openapi.json` returns a merged OpenAPI document scoped to the calling
|
||||||
|
API key: every operation the key holds a grant on, across all services,
|
||||||
|
rewritten to the gateway's own routes (`/{slug}/...`). `GET /docs` serves a
|
||||||
|
Swagger UI portal around it.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import config, discovery, security
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import ApiKey, Endpoint, Service
|
||||||
|
from app.proxy import _error, _pattern_to_regex
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_HTTP_METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
|
||||||
|
_REF_RE = re.compile(r"^#/components/([A-Za-z]+)/(.+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_refs(obj, slug: str):
|
||||||
|
"""Namespace `$ref`s into a service's renamed components (atlas_Error)."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
out = {}
|
||||||
|
for key, value in obj.items():
|
||||||
|
if key == "$ref" and isinstance(value, str):
|
||||||
|
m = _REF_RE.match(value)
|
||||||
|
out[key] = (f"#/components/{m.group(1)}/{slug}_{m.group(2)}"
|
||||||
|
if m else value)
|
||||||
|
else:
|
||||||
|
out[key] = _rewrite_refs(value, slug)
|
||||||
|
return out
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_rewrite_refs(item, slug) for item in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _grant_matches(grant: Endpoint, method: str, path: str) -> bool:
|
||||||
|
return (grant.method in ("*", method)
|
||||||
|
and _pattern_to_regex(grant.path).match(path) is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_paths(service: Service, grants: list[Endpoint], spec: dict | None) -> dict:
|
||||||
|
"""The slug-prefixed path entries of one service, filtered to the grants."""
|
||||||
|
slug = service.slug
|
||||||
|
if spec:
|
||||||
|
included = {}
|
||||||
|
for path, operations in spec.get("paths", {}).items():
|
||||||
|
if not isinstance(operations, dict):
|
||||||
|
continue
|
||||||
|
kept = {}
|
||||||
|
for method, op in operations.items():
|
||||||
|
if method.lower() not in _HTTP_METHODS or not isinstance(op, dict):
|
||||||
|
continue
|
||||||
|
if any(_grant_matches(g, method.upper(), path) for g in grants):
|
||||||
|
kept[method] = {**op, "tags": [service.name]}
|
||||||
|
if kept:
|
||||||
|
entry = {k: v for k, v in operations.items() if k.lower() not in _HTTP_METHODS}
|
||||||
|
entry.update(kept)
|
||||||
|
included[f"/{slug}{path}"] = entry
|
||||||
|
return _rewrite_refs(included, slug)
|
||||||
|
|
||||||
|
# No OpenAPI document upstream — synthesize minimal entries from the catalog.
|
||||||
|
included = {}
|
||||||
|
for g in grants:
|
||||||
|
methods = [g.method.lower()] if g.method != "*" else ["get", "post", "put", "patch", "delete"]
|
||||||
|
entry = included.setdefault(f"/{slug}{g.path}", {})
|
||||||
|
for method in methods:
|
||||||
|
entry[method] = {
|
||||||
|
"summary": g.description or ("(any method)" if g.method == "*" else ""),
|
||||||
|
"tags": [service.name],
|
||||||
|
"responses": {"default": {"description": "Proxied upstream response"}},
|
||||||
|
}
|
||||||
|
return included
|
||||||
|
|
||||||
|
|
||||||
|
def build_key_spec(db: Session, api_key: ApiKey, gateway_url: str) -> dict:
|
||||||
|
by_service: dict[int, list[Endpoint]] = {}
|
||||||
|
for grant in api_key.endpoints:
|
||||||
|
by_service.setdefault(grant.service_id, []).append(grant)
|
||||||
|
|
||||||
|
paths: dict = {}
|
||||||
|
components: dict = {}
|
||||||
|
tags: list[dict] = []
|
||||||
|
for service_id, grants in by_service.items():
|
||||||
|
service = grants[0].service
|
||||||
|
if not service.is_active:
|
||||||
|
continue
|
||||||
|
spec = discovery.get_spec(db, service)
|
||||||
|
paths.update(_service_paths(service, grants, spec))
|
||||||
|
if spec:
|
||||||
|
for section, items in spec.get("components", {}).items():
|
||||||
|
if section == "securitySchemes" or not isinstance(items, dict):
|
||||||
|
continue
|
||||||
|
renamed = {f"{service.slug}_{name}": schema for name, schema in items.items()}
|
||||||
|
components.setdefault(section, {}).update(_rewrite_refs(renamed, service.slug))
|
||||||
|
tags.append({"name": service.name, "description": service.description or f"/{service.slug}"})
|
||||||
|
|
||||||
|
components["securitySchemes"] = {
|
||||||
|
"ApiKeyAuth": {"type": "apiKey", "in": "header", "name": config.API_KEY_HEADER},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {
|
||||||
|
"title": "API Gateway",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": f"Operations available to API key **{api_key.name}**. "
|
||||||
|
f"Send the key in the `{config.API_KEY_HEADER}` header.",
|
||||||
|
},
|
||||||
|
"servers": [{"url": gateway_url}],
|
||||||
|
"tags": sorted(tags, key=lambda t: t["name"]),
|
||||||
|
"paths": dict(sorted(paths.items())),
|
||||||
|
"components": components,
|
||||||
|
"security": [{"ApiKeyAuth": []}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/openapi.json", include_in_schema=False)
|
||||||
|
def key_scoped_openapi(request: Request, db: Session = Depends(get_db)):
|
||||||
|
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.")
|
||||||
|
|
||||||
|
spec = build_key_spec(db, api_key, str(request.base_url).rstrip("/"))
|
||||||
|
return JSONResponse(spec, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
_DOCS_PAGE = """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>API Gateway · Docs</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; font-family: system-ui, sans-serif; }
|
||||||
|
#keybar {
|
||||||
|
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
||||||
|
padding: 14px 20px; background: #1b1b19; color: #e8e6df;
|
||||||
|
}
|
||||||
|
#keybar strong { font-size: 15px; }
|
||||||
|
#keybar input {
|
||||||
|
flex: 1; min-width: 260px; max-width: 480px; padding: 8px 10px;
|
||||||
|
border-radius: 8px; border: 1px solid #444; background: #121210;
|
||||||
|
color: #e8e6df; font-family: monospace;
|
||||||
|
}
|
||||||
|
#keybar button {
|
||||||
|
padding: 8px 16px; border-radius: 8px; border: 0;
|
||||||
|
background: #3987e5; color: #fff; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
#keybar .msg { font-size: 13px; color: #e66767; }
|
||||||
|
#placeholder { padding: 48px 20px; text-align: center; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="keybar">
|
||||||
|
<strong>API Gateway</strong>
|
||||||
|
<input id="apikey" type="password" placeholder="Paste your API key (X-API-Key)"
|
||||||
|
autocomplete="off" spellcheck="false">
|
||||||
|
<button id="load">Load my API docs</button>
|
||||||
|
<span class="msg" id="msg"></span>
|
||||||
|
</div>
|
||||||
|
<div id="placeholder">Enter your API key above to see the endpoints available to you.</div>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
const input = document.getElementById('apikey');
|
||||||
|
const msg = document.getElementById('msg');
|
||||||
|
input.value = sessionStorage.getItem('gw_api_key') || '';
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const key = input.value.trim();
|
||||||
|
msg.textContent = '';
|
||||||
|
if (!key) { msg.textContent = 'An API key is required.'; return; }
|
||||||
|
const resp = await fetch('/openapi.json', { headers: { 'X-API-Key': key } });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
msg.textContent = err.message || ('Error ' + resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionStorage.setItem('gw_api_key', key);
|
||||||
|
document.getElementById('placeholder').remove?.();
|
||||||
|
SwaggerUIBundle({
|
||||||
|
spec: await resp.json(),
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
requestInterceptor: req => { req.headers['X-API-Key'] = key; return req; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('load').addEventListener('click', load);
|
||||||
|
input.addEventListener('keydown', e => { if (e.key === 'Enter') load(); });
|
||||||
|
if (input.value) load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/docs", include_in_schema=False)
|
||||||
|
def docs_portal():
|
||||||
|
return HTMLResponse(_DOCS_PAGE)
|
||||||
Reference in New Issue
Block a user