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