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>
332 lines
14 KiB
Python
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)
|