Add two-level tag hierarchy to the docs portal
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d5878f6130
commit
b90ecaf61c
+94
-21
@@ -45,7 +45,9 @@ def _grant_matches(grant: Endpoint, method: str, path: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _service_paths(service: Service, grants: list[Endpoint], spec: dict | None) -> dict:
|
def _service_paths(service: Service, grants: list[Endpoint], spec: dict | None) -> dict:
|
||||||
"""The slug-prefixed path entries of one service, filtered to the grants."""
|
"""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
|
slug = service.slug
|
||||||
if spec:
|
if spec:
|
||||||
included = {}
|
included = {}
|
||||||
@@ -57,7 +59,7 @@ def _service_paths(service: Service, grants: list[Endpoint], spec: dict | None)
|
|||||||
if method.lower() not in _HTTP_METHODS or not isinstance(op, dict):
|
if method.lower() not in _HTTP_METHODS or not isinstance(op, dict):
|
||||||
continue
|
continue
|
||||||
if any(_grant_matches(g, method.upper(), path) for g in grants):
|
if any(_grant_matches(g, method.upper(), path) for g in grants):
|
||||||
kept[method] = {**op, "tags": [service.name]}
|
kept[method] = {**op, "tags": list(op.get("tags") or ["General"])}
|
||||||
if kept:
|
if kept:
|
||||||
entry = {k: v for k, v in operations.items() if k.lower() not in _HTTP_METHODS}
|
entry = {k: v for k, v in operations.items() if k.lower() not in _HTTP_METHODS}
|
||||||
entry.update(kept)
|
entry.update(kept)
|
||||||
@@ -72,33 +74,95 @@ def _service_paths(service: Service, grants: list[Endpoint], spec: dict | None)
|
|||||||
for method in methods:
|
for method in methods:
|
||||||
entry[method] = {
|
entry[method] = {
|
||||||
"summary": g.description or ("(any method)" if g.method == "*" else ""),
|
"summary": g.description or ("(any method)" if g.method == "*" else ""),
|
||||||
"tags": [service.name],
|
"tags": ["General"],
|
||||||
"responses": {"default": {"description": "Proxied upstream response"}},
|
"responses": {"default": {"description": "Proxied upstream response"}},
|
||||||
}
|
}
|
||||||
return included
|
return included
|
||||||
|
|
||||||
|
|
||||||
def build_key_spec(db: Session, api_key: ApiKey, gateway_url: str) -> dict:
|
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]] = {}
|
by_service: dict[int, list[Endpoint]] = {}
|
||||||
for grant in api_key.endpoints:
|
for grant in api_key.endpoints:
|
||||||
by_service.setdefault(grant.service_id, []).append(grant)
|
by_service.setdefault(grant.service_id, []).append(grant)
|
||||||
|
|
||||||
paths: dict = {}
|
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 = {}
|
components: dict = {}
|
||||||
tags: list[dict] = []
|
|
||||||
for service_id, grants in by_service.items():
|
ordered = sorted(by_service.values(), key=lambda g: g[0].service.name)
|
||||||
|
for grants in ordered:
|
||||||
service = grants[0].service
|
service = grants[0].service
|
||||||
if not service.is_active:
|
if not service.is_active:
|
||||||
continue
|
continue
|
||||||
spec = discovery.get_spec(db, service)
|
spec = discovery.get_spec(db, service)
|
||||||
paths.update(_service_paths(service, grants, spec))
|
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:
|
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():
|
for section, items in spec.get("components", {}).items():
|
||||||
if section == "securitySchemes" or not isinstance(items, dict):
|
if section == "securitySchemes" or not isinstance(items, dict):
|
||||||
continue
|
continue
|
||||||
renamed = {f"{service.slug}_{name}": schema for name, schema in items.items()}
|
renamed = {f"{service.slug}_{name}": schema for name, schema in items.items()}
|
||||||
components.setdefault(section, {}).update(_rewrite_refs(renamed, service.slug))
|
components.setdefault(section, {}).update(_rewrite_refs(renamed, service.slug))
|
||||||
tags.append({"name": service.name, "description": service.description or f"/{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"] = {
|
components["securitySchemes"] = {
|
||||||
"ApiKeyAuth": {"type": "apiKey", "in": "header", "name": config.API_KEY_HEADER},
|
"ApiKeyAuth": {"type": "apiKey", "in": "header", "name": config.API_KEY_HEADER},
|
||||||
@@ -108,12 +172,12 @@ def build_key_spec(db: Session, api_key: ApiKey, gateway_url: str) -> dict:
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "API Gateway",
|
"title": "API Gateway",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": f"Operations available to API key **{api_key.name}**. "
|
"description": "\n\n".join(intro),
|
||||||
f"Send the key in the `{config.API_KEY_HEADER}` header.",
|
|
||||||
},
|
},
|
||||||
"servers": [{"url": gateway_url}],
|
"servers": [{"url": gateway_url}],
|
||||||
"tags": sorted(tags, key=lambda t: t["name"]),
|
"tags": tags_out,
|
||||||
"paths": dict(sorted(paths.items())),
|
"x-tagGroups": tag_groups,
|
||||||
|
"paths": dict(sorted(paths_out.items())),
|
||||||
"components": components,
|
"components": components,
|
||||||
"security": [{"ApiKeyAuth": []}],
|
"security": [{"ApiKeyAuth": []}],
|
||||||
}
|
}
|
||||||
@@ -141,7 +205,6 @@ _DOCS_PAGE = """<!doctype html>
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>API Gateway · Docs</title>
|
<title>API Gateway · Docs</title>
|
||||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; font-family: system-ui, sans-serif; }
|
body { margin: 0; font-family: system-ui, sans-serif; }
|
||||||
#keybar {
|
#keybar {
|
||||||
@@ -171,12 +234,13 @@ _DOCS_PAGE = """<!doctype html>
|
|||||||
<span class="msg" id="msg"></span>
|
<span class="msg" id="msg"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="placeholder">Enter your API key above to see the endpoints available to you.</div>
|
<div id="placeholder">Enter your API key above to see the endpoints available to you.</div>
|
||||||
<div id="swagger-ui"></div>
|
<div id="app"></div>
|
||||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
||||||
<script>
|
<script>
|
||||||
const input = document.getElementById('apikey');
|
const input = document.getElementById('apikey');
|
||||||
const msg = document.getElementById('msg');
|
const msg = document.getElementById('msg');
|
||||||
input.value = sessionStorage.getItem('gw_api_key') || '';
|
input.value = sessionStorage.getItem('gw_api_key') || '';
|
||||||
|
let reference = null;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const key = input.value.trim();
|
const key = input.value.trim();
|
||||||
@@ -189,12 +253,21 @@ _DOCS_PAGE = """<!doctype html>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessionStorage.setItem('gw_api_key', key);
|
sessionStorage.setItem('gw_api_key', key);
|
||||||
document.getElementById('placeholder').remove?.();
|
document.getElementById('placeholder')?.remove();
|
||||||
SwaggerUIBundle({
|
const config = {
|
||||||
spec: await resp.json(),
|
content: await resp.json(),
|
||||||
dom_id: '#swagger-ui',
|
hideClientButton: true,
|
||||||
requestInterceptor: req => { req.headers['X-API-Key'] = key; return req; },
|
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);
|
document.getElementById('load').addEventListener('click', load);
|
||||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') load(); });
|
input.addEventListener('keydown', e => { if (e.key === 'Enter') load(); });
|
||||||
|
|||||||
Reference in New Issue
Block a user