Operations keep their upstream tags as the second grouping level, and each service becomes an x-tagGroups entry (first level). Tag names shared by several services are disambiguated with the service name. The intro renders one section per service with the upstream spec's title and info.description (the admin-entered description overrides). The /docs renderer switches from Swagger UI to Scalar, which renders x-tagGroups as a nested sidebar, keeps try-it-out, and gets the API key pre-filled into its authentication panel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""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.
|
|
Operations keep their upstream tags — they become the second grouping
|
|
level; untagged operations fall into 'General'."""
|
|
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": list(op.get("tags") or ["General"])}
|
|
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": ["General"],
|
|
"responses": {"default": {"description": "Proxied upstream response"}},
|
|
}
|
|
return included
|
|
|
|
|
|
def build_key_spec(db: Session, api_key: ApiKey, gateway_url: str) -> dict:
|
|
"""Two-level structure: each service is an `x-tagGroups` entry (first
|
|
level, with its description in the intro), containing the upstream's own
|
|
operation tags (second level). Tag names shared by several services are
|
|
disambiguated with the service name."""
|
|
by_service: dict[int, list[Endpoint]] = {}
|
|
for grant in api_key.endpoints:
|
|
by_service.setdefault(grant.service_id, []).append(grant)
|
|
|
|
merged: list[tuple[Service, dict]] = [] # (service, its filtered paths)
|
|
used_tags: dict[int, list[str]] = {} # service id -> upstream tags, in order
|
|
tag_descriptions: dict[tuple[int, str], str] = {}
|
|
upstream_infos: dict[int, dict] = {} # service id -> upstream spec's `info`
|
|
components: dict = {}
|
|
|
|
ordered = sorted(by_service.values(), key=lambda g: g[0].service.name)
|
|
for grants in ordered:
|
|
service = grants[0].service
|
|
if not service.is_active:
|
|
continue
|
|
spec = discovery.get_spec(db, service)
|
|
paths = _service_paths(service, grants, spec)
|
|
if not paths:
|
|
continue
|
|
order: list[str] = []
|
|
for entry in paths.values():
|
|
for method, op in entry.items():
|
|
if method.lower() in _HTTP_METHODS and isinstance(op, dict):
|
|
for tag in op.get("tags", []):
|
|
if tag not in order:
|
|
order.append(tag)
|
|
used_tags[service.id] = order
|
|
if spec:
|
|
info = spec.get("info")
|
|
upstream_infos[service.id] = info if isinstance(info, dict) else {}
|
|
for tag in spec.get("tags") or []:
|
|
if isinstance(tag, dict) and tag.get("name") in order:
|
|
tag_descriptions[(service.id, tag["name"])] = tag.get("description", "")
|
|
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))
|
|
merged.append((service, paths))
|
|
|
|
# A tag name used by several services must stay unique in the flat tag list.
|
|
tag_counts: dict[str, int] = {}
|
|
for names in used_tags.values():
|
|
for name in names:
|
|
tag_counts[name] = tag_counts.get(name, 0) + 1
|
|
|
|
def final_name(service: Service, tag: str) -> str:
|
|
return tag if tag_counts[tag] == 1 else f"{tag} ({service.name})"
|
|
|
|
paths_out: dict = {}
|
|
tags_out: list[dict] = []
|
|
tag_groups: list[dict] = []
|
|
intro = [f"Operations available to API key **{api_key.name}**. "
|
|
f"Send the key in the `{config.API_KEY_HEADER}` header."]
|
|
for service, paths in merged:
|
|
finals = []
|
|
for tag in used_tags[service.id]:
|
|
name = final_name(service, tag)
|
|
finals.append(name)
|
|
tags_out.append({"name": name,
|
|
"description": tag_descriptions.get((service.id, tag), "")})
|
|
for entry in paths.values():
|
|
for method, op in entry.items():
|
|
if method.lower() in _HTTP_METHODS and isinstance(op, dict):
|
|
op["tags"] = [final_name(service, t) for t in op.get("tags", ["General"])]
|
|
paths_out.update(paths)
|
|
tag_groups.append({"name": service.name, "tags": finals})
|
|
info = upstream_infos.get(service.id, {})
|
|
section = f"## {service.name}\n\n"
|
|
title = info.get("title", "")
|
|
if title and title.lower() != service.name.lower():
|
|
section += f"*{title}*\n\n"
|
|
# The admin-entered description overrides the upstream's own.
|
|
description = service.description or info.get("description") or ""
|
|
if description:
|
|
section += description.strip() + "\n\n"
|
|
section += f"Routes under `/{service.slug}/…`"
|
|
intro.append(section)
|
|
|
|
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": "\n\n".join(intro),
|
|
},
|
|
"servers": [{"url": gateway_url}],
|
|
"tags": tags_out,
|
|
"x-tagGroups": tag_groups,
|
|
"paths": dict(sorted(paths_out.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>
|
|
<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="app"></div>
|
|
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
<script>
|
|
const input = document.getElementById('apikey');
|
|
const msg = document.getElementById('msg');
|
|
input.value = sessionStorage.getItem('gw_api_key') || '';
|
|
let reference = null;
|
|
|
|
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();
|
|
const config = {
|
|
content: await resp.json(),
|
|
hideClientButton: true,
|
|
authentication: {
|
|
preferredSecurityScheme: 'ApiKeyAuth',
|
|
securitySchemes: { ApiKeyAuth: { value: key, token: key } },
|
|
apiKey: { token: key },
|
|
},
|
|
};
|
|
if (reference) {
|
|
reference.destroy?.();
|
|
document.getElementById('app').innerHTML = '';
|
|
}
|
|
reference = Scalar.createApiReference('#app', config);
|
|
}
|
|
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)
|