Files
Samuel AmarandClaude Fable 5 9a3feccc90 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>
2026-09-02 11:39:07 +02:00

332 lines
14 KiB
Python

"""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)