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:
Samuel Amar
2026-07-29 14:27:44 +02:00
co-authored by Claude Fable 5
parent 77d7a50fa9
commit d5878f6130
4 changed files with 231 additions and 2 deletions
+11
View File
@@ -15,6 +15,7 @@ SPEC_PATHS = ("/openapi.json", "/swagger.json", "/api-docs")
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
_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:
@@ -51,9 +52,18 @@ def sync_service(db: Session, service: Service, force: bool = False) -> tuple[bo
_spec_found[service.id] = spec is not None
if spec is None:
return False, False
_spec_cache[service.id] = 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:
"""Connectivity probe for the admin UI. Prefers the OpenAPI document —
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:
_last_sync[service.id] = time.monotonic()
_spec_found[service.id] = True
_spec_cache[service.id] = spec
apply_spec(db, service, spec)
return {"ok": True, "spec_found": True,
"endpoints": len(service.endpoints),
+6 -2
View File
@@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
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 stats as admin_stats
from app.admin.deps import LoginRequired, login_redirect_handler
@@ -111,7 +111,10 @@ async def lifespan(app: FastAPI):
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)
@@ -127,6 +130,7 @@ def health():
app.include_router(admin_routes.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")
# The proxy catch-all (/{slug}/...) must come last so it never shadows
# /admin, /static, /docs or /health.
+209
View File
@@ -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)