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
@@ -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
@@ -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
@@ -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