Add hierarchical key-scoped catalog API for machine consumers

GET /catalog walks agents through progressive disclosure: available
APIs with descriptions, then one API's tags, then a tag's endpoints,
then full detail for a single operation with $refs resolved inline.
Oversized tags (tag-poor upstreams) fall back to path-prefix groups,
drillable with ?prefix= and compressed through single-child chains.

Responses are filtered to the key's grants, carry ETags for cheap
revalidation, and reuse the discovery spec cache. Key auth is shared
with the docs portal via portal.resolve_api_key; 'catalog' and 'mcp'
are now reserved slugs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Amar
2026-09-02 11:39:07 +02:00
co-authored by Claude Fable 5
parent b90ecaf61c
commit 9a3feccc90
5 changed files with 352 additions and 4 deletions
+2 -1
View File
@@ -13,7 +13,8 @@ router = APIRouter(prefix="/admin")
templates = Jinja2Templates(directory=str(config.BASE_DIR / "app" / "templates"))
# Slugs the proxy catch-all must never claim
RESERVED_SLUGS = {"admin", "static", "health", "docs", "redoc", "openapi.json"}
RESERVED_SLUGS = {"admin", "static", "health", "docs", "redoc", "openapi.json",
"catalog", "mcp"}
def build_tree(endpoints: list[Endpoint]) -> dict:
+331
View File
@@ -0,0 +1,331 @@
"""Hierarchical, key-scoped endpoint catalog for machine consumers (MCP).
Designed for progressive disclosure so an agent is never flooded:
GET /catalog -> the APIs this key can reach
GET /catalog/{slug} -> one API's tags (logical sections)
GET /catalog/{slug}/tags/{tag} -> endpoints of one tag; when a tag
is too large the response groups
by path prefix (?prefix= drills)
GET /catalog/{slug}/operation?method=&path=
-> full detail for one operation,
with $refs resolved inline
Everything is authenticated with X-API-Key and filtered to the key's grants.
Responses carry ETags so a polling MCP can revalidate cheaply.
"""
import hashlib
import json
from urllib.parse import quote, urlencode
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app import discovery
from app.database import get_db
from app.models import ApiKey, Endpoint, Service
from app.portal import _HTTP_METHODS, _grant_matches, resolve_api_key
from app.proxy import _error
router = APIRouter()
MAX_LISTING = 50 # above this, a tag listing groups by path prefix
MAX_REF_DEPTH = 10
def _respond(request: Request, payload: dict) -> Response:
body = json.dumps(payload, ensure_ascii=False).encode()
etag = '"' + hashlib.md5(body).hexdigest() + '"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag})
return Response(body, media_type="application/json",
headers={"ETag": etag, "Cache-Control": "private, must-revalidate"})
def _grants_by_service(api_key: ApiKey) -> dict[int, list[Endpoint]]:
grouped: dict[int, list[Endpoint]] = {}
for grant in api_key.endpoints:
if grant.service.is_active:
grouped.setdefault(grant.service_id, []).append(grant)
return grouped
def _granted_operations(db: Session, service: Service,
grants: list[Endpoint]) -> tuple[list[dict], dict | None]:
"""The operations this key may call on one service, as
[{method, path (gateway form), tags, summary, operationId}]. Falls back to
the endpoint catalog when the upstream has no OpenAPI document."""
spec = discovery.get_spec(db, service)
ops: list[dict] = []
if spec:
for path, operations in spec.get("paths", {}).items():
if not isinstance(operations, dict):
continue
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):
ops.append({
"method": method.upper(),
"path": f"/{service.slug}{path}",
"tags": list(op.get("tags") or ["General"]),
"summary": op.get("summary") or op.get("description", "")[:200],
"operationId": op.get("operationId"),
})
return ops, spec
for g in grants:
ops.append({
"method": "ANY" if g.method == "*" else g.method,
"path": f"/{service.slug}{g.path}",
"tags": ["General"],
"summary": g.description or "",
"operationId": None,
})
return ops, None
def _service_summary(service: Service, spec: dict | None) -> dict:
info = (spec or {}).get("info") or {}
return {
"slug": service.slug,
"name": service.name,
"title": info.get("title", ""),
# The admin-entered description overrides the upstream's own.
"description": service.description or info.get("description", ""),
"route_prefix": f"/{service.slug}",
}
def _tag_summaries(ops: list[dict], spec: dict | None, slug: str) -> list[dict]:
order: list[str] = []
counts: dict[str, int] = {}
for op in ops:
for tag in op["tags"]:
if tag not in counts:
order.append(tag)
counts[tag] = counts.get(tag, 0) + 1
descriptions = {}
for tag in (spec or {}).get("tags") or []:
if isinstance(tag, dict) and tag.get("name"):
descriptions[tag["name"]] = tag.get("description", "")
return [{"name": name,
"description": descriptions.get(name, ""),
"endpoints": counts[name],
"url": f"/catalog/{slug}/tags/{quote(name, safe='')}"}
for name in order]
def _group_by_prefix(ops: list[dict], prefix: str) -> tuple[list[dict], list[dict]]:
"""Bucket operations by the next path segment after `prefix`, compressing
single-child chains (/api + /v1 -> /api/v1). Returns (groups, operations
that sit exactly at the prefix)."""
at_prefix: list[dict] = []
buckets: dict[str, list[dict]] = {}
base = prefix.rstrip("/")
for op in ops:
rest = op["path"][len(base):].lstrip("/")
if not rest:
at_prefix.append(op)
continue
buckets.setdefault(rest.split("/")[0], []).append(op)
groups = []
for segment, items in sorted(buckets.items()):
group_prefix = f"{base}/{segment}"
while True: # chain compression
rests = {op["path"][len(group_prefix):].lstrip("/").split("/")[0]
for op in items}
rests.discard("")
if len(rests) == 1 and all(len(op["path"]) > len(group_prefix) for op in items):
group_prefix = f"{group_prefix}/{rests.pop()}"
else:
break
groups.append({"prefix": group_prefix, "endpoints": len(items)})
return groups, at_prefix
def _endpoint_entry(op: dict, slug: str) -> dict:
detail = urlencode({"method": op["method"], "path": op["path"]})
return {"method": op["method"], "path": op["path"], "summary": op["summary"],
**({"operationId": op["operationId"]} if op["operationId"] else {}),
"url": f"/catalog/{slug}/operation?{detail}"}
def _resolve_refs(node, spec: dict, depth: int = 0, seen: frozenset = frozenset()):
"""Inline every local $ref so a consumer never has to chase references.
Cycles and excessive depth degrade to a named placeholder."""
if depth > MAX_REF_DEPTH:
return {"$truncated": True}
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith("#/"):
if ref in seen:
return {"$circular": ref.rsplit("/", 1)[-1]}
target = spec
for part in ref[2:].split("/"):
target = target.get(part) if isinstance(target, dict) else None
if target is None:
return node # dangling ref: leave as-is
resolved = _resolve_refs(target, spec, depth + 1, seen | {ref})
if isinstance(resolved, dict):
extras = {k: v for k, v in node.items() if k != "$ref"}
return {**resolved, **extras}
return resolved
return {k: _resolve_refs(v, spec, depth + 1, seen) for k, v in node.items()}
if isinstance(node, list):
return [_resolve_refs(item, spec, depth + 1, seen) for item in node]
return node
def _find_service(db: Session, api_key: ApiKey, slug: str):
grouped = _grants_by_service(api_key)
for service_id, grants in grouped.items():
service = grants[0].service
if service.slug == slug:
return service, grants
return None, None
# ---------- level 1: available APIs ----------
@router.get("/catalog", include_in_schema=False)
def catalog_root(request: Request, db: Session = Depends(get_db)):
api_key = resolve_api_key(request, db)
if isinstance(api_key, JSONResponse):
return api_key
apis = []
for grants in sorted(_grants_by_service(api_key).values(),
key=lambda g: g[0].service.name):
service = grants[0].service
ops, spec = _granted_operations(db, service, grants)
if not ops:
continue
tags = _tag_summaries(ops, spec, service.slug)
apis.append({**_service_summary(service, spec),
"tags": len(tags), "endpoints": len(ops),
"url": f"/catalog/{service.slug}"})
return _respond(request, {
"key": api_key.name,
"apis": apis,
"hint": "GET the api url for its tags, then a tag url for its endpoints, "
"then an endpoint url for the full schema. Call endpoints through "
"this gateway with the X-API-Key header.",
})
# ---------- level 2: one API's tags ----------
@router.get("/catalog/{slug}", include_in_schema=False)
def catalog_service(slug: str, request: Request, db: Session = Depends(get_db)):
api_key = resolve_api_key(request, db)
if isinstance(api_key, JSONResponse):
return api_key
service, grants = _find_service(db, api_key, slug)
if service is None:
return _error(404, "unknown_api",
f"No API '{slug}' is available to this key. GET /catalog for the list.")
ops, spec = _granted_operations(db, service, grants)
return _respond(request, {
**_service_summary(service, spec),
"endpoints": len(ops),
"tags": _tag_summaries(ops, spec, slug),
})
# ---------- level 3: one tag's endpoints ----------
@router.get("/catalog/{slug}/tags/{tag}", include_in_schema=False)
def catalog_tag(slug: str, tag: str, request: Request,
prefix: str | None = None, db: Session = Depends(get_db)):
api_key = resolve_api_key(request, db)
if isinstance(api_key, JSONResponse):
return api_key
service, grants = _find_service(db, api_key, slug)
if service is None:
return _error(404, "unknown_api",
f"No API '{slug}' is available to this key. GET /catalog for the list.")
ops, _ = _granted_operations(db, service, grants)
ops = [op for op in ops if tag in op["tags"]]
if not ops:
return _error(404, "unknown_tag",
f"No tag '{tag}' on '{slug}'. GET /catalog/{slug} for its tags.")
if prefix:
ops = [op for op in ops if op["path"].startswith(prefix)]
payload = {"api": slug, "tag": tag, "total": len(ops)}
if prefix:
payload["prefix"] = prefix
if len(ops) > MAX_LISTING:
groups, at_prefix = _group_by_prefix(ops, prefix or f"/{slug}")
# A single group would force a pointless extra round trip — split it.
while len(groups) == 1 and not at_prefix:
groups, at_prefix = _group_by_prefix(ops, groups[0]["prefix"])
payload["endpoints"] = [_endpoint_entry(op, slug) for op in at_prefix]
payload["groups"] = [{**g, "url": f"/catalog/{slug}/tags/{quote(tag, safe='')}"
f"?{urlencode({'prefix': g['prefix']})}"}
for g in groups]
payload["hint"] = (f"{len(ops)} endpoints — too many to list. "
"GET a group url to drill into that path prefix.")
else:
payload["endpoints"] = [_endpoint_entry(op, slug) for op in ops]
return _respond(request, payload)
# ---------- level 4: one operation, full detail ----------
@router.get("/catalog/{slug}/operation", include_in_schema=False)
def catalog_operation(slug: str, method: str, path: str, request: Request,
db: Session = Depends(get_db)):
api_key = resolve_api_key(request, db)
if isinstance(api_key, JSONResponse):
return api_key
service, grants = _find_service(db, api_key, slug)
if service is None:
return _error(404, "unknown_api",
f"No API '{slug}' is available to this key. GET /catalog for the list.")
# Accept the gateway form (/slug/...) as listed, or the upstream path.
upstream_path = path[len(f"/{slug}"):] if path.startswith(f"/{slug}/") else path
method = method.upper()
spec = discovery.get_spec(db, service)
op = None
if spec:
operations = spec.get("paths", {}).get(upstream_path)
if isinstance(operations, dict):
candidate = operations.get(method.lower())
if isinstance(candidate, dict):
op = candidate
if not any(_grant_matches(g, method, upstream_path) for g in grants):
return _error(404, "unknown_operation",
f"{method} {path} is not available to this key on '{slug}'.")
gateway_path = f"/{slug}{upstream_path}"
detail = {
"method": method,
"path": gateway_path,
"api": slug,
"auth": "Send the API key in the X-API-Key header.",
}
if op:
for field in ("operationId", "summary", "description", "tags",
"parameters", "requestBody", "responses", "deprecated"):
if field in op:
detail[field] = _resolve_refs(op[field], spec)
# path-level parameters apply to every operation of the path
shared = spec["paths"][upstream_path].get("parameters")
if shared:
detail.setdefault("parameters", [])
detail["parameters"] = _resolve_refs(shared, spec) + detail["parameters"]
else:
grant = next(g for g in grants if _grant_matches(g, method, upstream_path))
detail["summary"] = grant.description or ""
detail["note"] = ("The upstream publishes no OpenAPI document; "
"no schema information is available.")
return _respond(request, detail)
+2 -1
View File
@@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
from sqlalchemy import text
from app import config, portal, proxy, retention, security
from app import catalog, 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
@@ -131,6 +131,7 @@ def health():
app.include_router(admin_routes.router)
app.include_router(admin_stats.router)
app.include_router(portal.router)
app.include_router(catalog.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.
+11 -2
View File
@@ -183,8 +183,9 @@ def build_key_spec(db: Session, api_key: ApiKey, gateway_url: str) -> dict:
}
@router.get("/openapi.json", include_in_schema=False)
def key_scoped_openapi(request: Request, db: Session = Depends(get_db)):
def resolve_api_key(request: Request, db: Session) -> ApiKey | JSONResponse:
"""Authenticate a consumer-facing request by X-API-Key. Returns the key,
or the error response to send back."""
plain_key = request.headers.get(config.API_KEY_HEADER)
if not plain_key:
return _error(401, "missing_api_key",
@@ -194,6 +195,14 @@ def key_scoped_openapi(request: Request, db: Session = Depends(get_db)):
.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.")
return api_key
@router.get("/openapi.json", include_in_schema=False)
def key_scoped_openapi(request: Request, db: Session = Depends(get_db)):
api_key = resolve_api_key(request, db)
if isinstance(api_key, JSONResponse):
return api_key
spec = build_key_spec(db, api_key, str(request.base_url).rstrip("/"))
return JSONResponse(spec, headers={"Cache-Control": "no-store"})