Files
MoviePilot/scripts/generate_agent_api_surface_audit.py
2026-10-15 22:28:58 +08:00

441 lines
19 KiB
Python

#!/usr/bin/env python3
"""Generate the complete OpenAPI-to-Agent surface audit."""
from __future__ import annotations
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from fastapi import FastAPI
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from app.agent.policy.api import API_OPERATION_ROUTES, API_OPERATION_SPECS # noqa: E402
from app.api.apiv1 import api_router # noqa: E402
JSON_OUTPUT = PROJECT_ROOT / "docs/refactor/agent-api-surface-audit.json"
MARKDOWN_OUTPUT = PROJECT_ROOT / "docs/refactor/agent-api-surface-audit.md"
HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
TRANSPORT_TAGS = frozenset(
{
"agent",
"anthropic",
"auth",
"llm",
"login",
"mcp",
"message",
"mfa",
"notification",
"openai",
"user",
"webhook",
}
)
CONSOLIDATED_TAGS = frozenset(
{
"anilist",
"bangumi",
"discover",
"douban",
"recommend",
"tmdb",
}
)
PROVIDER_PATH_PREFIXES = (
"/api/v1/download/",
"/api/v1/mediaserver/",
)
CONSOLIDATED_ROUTE_OWNERS: dict[tuple[str, str], str] = {
("POST", "/api/v1/download/"): "download.add",
("GET", "/api/v1/plugin/installed"): "plugin.installed",
("GET", "/api/v1/plugin/source/{plugin_id}/options"): "plugin.source.options",
("GET", "/api/v1/plugin/{plugin_id}"): "plugin.config.get",
("GET", "/api/v1/search/last"): "search.results",
("GET", "/api/v1/search/media/{media_id}/stream"): "search.torrents",
("GET", "/api/v1/search/subtitle/media/{media_id}/stream"): "subtitle.search.media",
("GET", "/api/v1/search/subtitle/title/stream"): "subtitle.search.title",
("GET", "/api/v1/search/title/stream"): "search.title",
("GET", "/api/v1/site/"): "site.list",
("GET", "/api/v1/site/cookie/{site_id}"): "site.cookie.update",
("GET", "/api/v1/site/domain/{site_url}"): "site.list",
("GET", "/api/v1/site/{site_id}"): "site.list",
("POST", "/api/v1/storage/list"): "storage.list",
("GET", "/api/v1/subscribe/list"): "subscription.list",
("GET", "/api/v1/system/env"): "config.system.get",
("POST", "/api/v1/system/env"): "config.system.update",
("GET", "/api/v1/system/global"): "config.system.get",
("GET", "/api/v1/system/setting/{key}"): "config.system.get",
("POST", "/api/v1/system/setting/{key}"): "config.system.update",
("GET", "/api/v1/transfer/now"): "scheduler.run",
("GET", "/api/v1/workflow/"): "workflow.list",
("GET", "/api/v1/media/category"): "media.classification.policy.get",
("GET", "/api/v1/media/category/config"): "media.classification.policy.get",
}
STREAM_OR_BINARY_PATHS = frozenset(
{
"/api/v1/plugin/file/{plugin_id}/{filepath}",
"/api/v1/site/icon/{site_id}",
"/api/v1/storage/download",
"/api/v1/storage/image",
"/api/v1/system/cache/image",
"/api/v1/system/img/{proxy}",
"/api/v1/subscribe/{subscription_id}/logs",
"/api/v1/subscribe/{subscription_id}/logs/download",
"/api/v1/system/logging",
"/api/v1/system/logging/download/{name}",
"/api/v1/system/message",
"/api/v1/system/notification-image",
"/api/v1/system/progress/{process_type}",
}
)
UI_PRESENTATION_PATHS = frozenset(
{
"/api/v1/plugin/agent_pets",
"/api/v1/plugin/dashboard/meta",
"/api/v1/plugin/dashboard/{plugin_id}",
"/api/v1/plugin/dashboard/{plugin_id}/{key}",
"/api/v1/plugin/page/{plugin_id}",
"/api/v1/plugin/runtime/{plugin_id}/data/summary",
"/api/v1/plugin/sidebar_nav",
"/api/v1/rule/custom/reorder",
"/api/v1/rule/groups/reorder",
"/api/v1/storage/catalog",
"/api/v1/storage/options",
"/api/v1/transfer/queue/page",
}
)
EXPLICIT_TRANSPORT_PATHS = frozenset(
{
"/api/v1/plugin/remotes",
"/api/v1/subscribe/seerr",
"/api/v1/system/ping",
}
)
GITHUB_AUTH_PATH_PREFIXES = (
"/api/v1/github/auth/",
"/api/v1/login/github-auth/",
)
SUBSCRIPTION_EXECUTION_UI_PREFIX = "/api/v1/subscribe/execution/"
MUSIC_LIBRARY_STATUS_UI_PATH = "/api/v1/music/library/status"
def _gateway_routes() -> dict[tuple[str, str], list[str]]:
"""Return exact HTTP routes and every stable gateway operation using them."""
results: dict[tuple[str, str], list[str]] = defaultdict(list)
for operation_id, route in API_OPERATION_ROUTES.items():
results[(route.method.upper(), route.path)].append(operation_id)
return {key: sorted(values) for key, values in results.items()}
def _classify(
*,
method: str,
path: str,
tags: list[str],
gateway_routes: dict[tuple[str, str], list[str]],
) -> tuple[str, str, str, list[str]]:
"""把 OpenAPI 操作归入经过审查的 Agent 或直接管理端契约。"""
operations = gateway_routes.get((method, path), [])
if operations:
return (
"gateway",
"moviepilot-api",
"Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
operations,
)
primary_tag = tags[0] if tags else "untagged"
if primary_tag in TRANSPORT_TAGS:
return (
"transport_or_identity",
"host-runtime",
"Authentication, protocol compatibility, conversation transport, callback, or account lifecycle endpoint; never recursively exposed as an Agent business action.",
[],
)
consolidated_owner = CONSOLIDATED_ROUTE_OWNERS.get((method, path))
if consolidated_owner:
return (
"consolidated",
"moviepilot-api",
f"This compatibility, broader-response, or UI route is represented by the safer stable operation {consolidated_owner}.",
[consolidated_owner],
)
if path in STREAM_OR_BINARY_PATHS:
return (
"stream_or_binary",
"host-transport",
"Streaming, image, archive, or file response consumed by a direct client; the structured JSON Agent gateway does not proxy binary or unbounded streams.",
[],
)
if path in UI_PRESENTATION_PATHS:
return (
"ui_presentation",
"host-ui",
"Plugin-rendered page, dashboard, or navigation metadata owned by the frontend presentation contract rather than an Agent business action.",
[],
)
if method == "POST" and path == "/api/v1/history/transfer/{history_id}/cleanup-resolved":
return (
"ui_presentation",
"host-ui",
"Manual confirmation of downloader cleanup is owned by the authenticated history management workflow; it is not an Agent action that verifies external cleanup.",
[],
)
if path == "/api/v1/history/transfer/{history_id}/discard-corrupt":
return (
"ui_presentation",
"host-ui",
"Discarding corrupt transfer state is owned by the authenticated management recovery workflow; it is not a stable Agent gateway operation.",
[],
)
if path.startswith(SUBSCRIPTION_EXECUTION_UI_PREFIX) or path in {
"/api/v1/subscribe/{subscription_id}/activity",
"/api/v1/subscribe/{subscription_id}/activity/{event_id}",
}:
return (
"ui_presentation",
"host-ui",
"Subscription activity details, background execution status and cancellation are owned by the authenticated frontend workflow; they are not yet a stable Agent gateway contract.",
[],
)
if method == "POST" and path == MUSIC_LIBRARY_STATUS_UI_PATH:
return (
"ui_presentation",
"host-ui",
"Batch music-library presence is a bounded projection for the authenticated artist resource matrix; it is not a standalone Agent business action.",
[],
)
if path in EXPLICIT_TRANSPORT_PATHS:
return (
"transport_or_identity",
"host-runtime",
"Health, bootstrap, federation, or external webhook transport endpoint; it is not recursively callable as an Agent business action.",
[],
)
if method == "GET" and path == "/api/v1/github/community":
return (
"ui_presentation",
"host-ui",
"Community medals are a presentation of the administrator-configured GitHub identity, not an Agent business operation.",
[],
)
if path.startswith(GITHUB_AUTH_PATH_PREFIXES):
return (
"transport_or_identity",
"host-runtime",
"GitHub Token authorization and first-run credential setup are host identity/configuration flows; they are not Agent business actions.",
[],
)
if path.startswith(PROVIDER_PATH_PREFIXES):
return (
"provider-skill",
"downloader-operation" if "/download/" in path else "mediaserver-operation",
"Low-level provider behavior is exposed by the self-describing provider Skill; high-level MoviePilot operations remain in moviepilot-api.",
[],
)
if primary_tag in CONSOLIDATED_TAGS:
return (
"consolidated",
"moviepilot-api",
"Source-specific or presentation-oriented route is represented by a stable aggregate search, detail, person, recommendation, or music operation instead of duplicating every frontend route.",
[],
)
if path.endswith("2") or "/schedule2" in path or "/recognize2" in path or "/recognize_file2" in path:
return (
"alternate-auth-duplicate",
"moviepilot-api",
"API-token compatibility duplicate; the Agent uses the corresponding bearer-authenticated operation with its persisted user identity.",
[],
)
raise ValueError(f"Unclassified OpenAPI operation: {method} {path}")
def generate_audit() -> dict[str, Any]:
"""Build a deterministic entry for every v1 OpenAPI HTTP operation."""
app = FastAPI()
app.include_router(api_router, prefix="/api/v1")
openapi = app.openapi()
gateway_routes = _gateway_routes()
entries = []
for path, path_item in sorted(openapi.get("paths", {}).items()):
for raw_method, operation in sorted(path_item.items()):
method = raw_method.upper()
if method not in HTTP_METHODS or not isinstance(operation, dict):
continue
tags = [str(tag) for tag in operation.get("tags") or []]
disposition, owner, reason, operation_ids = _classify(
method=method,
path=path,
tags=tags,
gateway_routes=gateway_routes,
)
entries.append(
{
"method": method,
"path": path,
"tags": tags,
"summary": str(operation.get("summary") or ""),
"disposition": disposition,
"owner": owner,
"operation_ids": operation_ids,
"reason": reason,
}
)
counts = Counter(entry["disposition"] for entry in entries)
matched_gateway_routes = {
(entry["method"], entry["path"]) for entry in entries if entry["disposition"] == "gateway"
}
dynamic_gateway_routes = [
{
"method": method,
"path": path,
"operation_ids": operation_ids,
"reason": (
"The executor validates and expands this bounded source placeholder to one of "
"tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route."
),
}
for (method, path), operation_ids in sorted(gateway_routes.items())
if (method, path) not in matched_gateway_routes
]
return {
"openapi_operation_count": len(entries),
"gateway_operation_count": len(API_OPERATION_ROUTES),
"gateway_http_route_count": len(gateway_routes),
"matched_gateway_http_route_count": len(matched_gateway_routes),
"dynamic_gateway_routes": dynamic_gateway_routes,
"disposition_counts": dict(sorted(counts.items())),
"operations": entries,
"gateway_identity_contracts": [
{
"operation_id": spec.operation_id,
"method": API_OPERATION_ROUTES[spec.operation_id].method,
"path": API_OPERATION_ROUTES[spec.operation_id].path,
"identity_scope": spec.identity_scope.value,
"required_role": spec.required_role.value,
"effect": spec.effect.value,
"result_sensitivity": spec.result_sensitivity.value,
"confirmation": spec.confirmation.value,
}
for spec in sorted(API_OPERATION_SPECS, key=lambda item: item.operation_id)
],
}
def render_markdown(audit: dict[str, Any]) -> str:
"""Render the complete audit as a reviewable architecture document."""
lines = [
"# MoviePilot Agent API Surface Audit",
"",
"> Generated from the v1 FastAPI OpenAPI document and the fixed Agent API registry.",
"> Do not edit route rows manually; run `scripts/generate_agent_api_surface_audit.py`.",
"",
"## Result",
"",
f"- OpenAPI HTTP operations: **{audit['openapi_operation_count']}**",
f"- Stable `moviepilot_api` operations: **{audit['gateway_operation_count']}**",
f"- Exact HTTP routes used by the gateway: **{audit['gateway_http_route_count']}**",
f"- OpenAPI routes matched directly by the gateway: **{audit['matched_gateway_http_route_count']}**",
f"- Bounded dynamic gateway routes: **{len(audit['dynamic_gateway_routes'])}**",
"- Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`.",
"- Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input.",
"",
"## Dispositions",
"",
"| disposition | count | meaning |",
"| :--- | ---: | :--- |",
]
meanings = {
"gateway": "Approved structured MoviePilot Agent operation.",
"provider-skill": "Low-level downloader or media-server capability owned by a provider Skill.",
"consolidated": "Source/UI route represented by a stable aggregate Agent operation.",
"alternate-auth-duplicate": "API-token compatibility duplicate of a bearer-authenticated capability.",
"transport_or_identity": "Authentication, protocol, callback, account, or conversation transport boundary.",
"stream_or_binary": "Streaming or binary response owned by a direct client transport.",
"ui_presentation": "Frontend or plugin-rendered presentation contract.",
}
for disposition, count in audit["disposition_counts"].items():
lines.append(f"| `{disposition}` | {count} | {meanings[disposition]} |")
lines.extend([
"",
"## Gateway Identity Audit",
"",
"All gateway operations are audited below. New operations default to bound_user; safe_read alone never grants public access.",
"public: audited external public metadata only; the host-authenticated executor cannot call other operations.",
"bound_user: an active account bound to the stable channel sender ID, or the authenticated Web user ID; endpoint ownership and management permissions still apply.",
"admin: administrator authority, preserving existing verified channel/API integration semantics; this never substitutes for ownership on bound_user operations.",
"Title recognition retains bound_user because its Context exposes locally configured applied identification rules. File recognition, source availability, rules, search results, media libraries, storage and public UI settings remain instance data.",
"Plugin ratings include this installation's own rating and require the same administrator role as their REST dependencies. Community share/popularity/statistics and global plugin installation statistics are public reads; follow lists and rating submissions are not.",
"",
"| operation | method | path | identity scope | role | effect | result sensitivity | confirmation |",
"| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |",
])
for item in audit["gateway_identity_contracts"]:
lines.append(
f"| `{item['operation_id']}` | `{item['method']}` | `{item['path']}` | "
f"`{item['identity_scope']}` | `{item['required_role']}` | `{item['effect']}` | "
f"`{item['result_sensitivity']}` | `{item['confirmation']}` |"
)
if audit["dynamic_gateway_routes"]:
lines.extend(
[
"",
"## Bounded Dynamic Routes",
"",
"| method | route template | operations | constraint |",
"| :--- | :--- | :--- | :--- |",
]
)
for item in audit["dynamic_gateway_routes"]:
lines.append(
f"| `{item['method']}` | `{item['path']}` | {', '.join(item['operation_ids'])} | {item['reason']} |"
)
lines.extend(
[
"",
"## Complete Route Inventory",
"",
"| method | path | tags | disposition | owner / operation | summary |",
"| :--- | :--- | :--- | :--- | :--- | :--- |",
]
)
for item in audit["operations"]:
tags = ", ".join(item["tags"]) or "-"
owner = ", ".join(item["operation_ids"]) or item["owner"]
summary = item["summary"].replace("|", "\\|")
lines.append(
f"| `{item['method']}` | `{item['path']}` | {tags} | `{item['disposition']}` | {owner} | {summary} |"
)
lines.extend(
[
"",
"## Exposure Rule",
"",
"Every structured JSON business endpoint is either a stable gateway operation, a provider Skill capability, or an explicitly consolidated compatibility route. Authentication, webhook, stream, binary, and UI-presentation endpoints remain owned by their direct transport or frontend consumer and must not be made recursively callable by the Agent.",
"",
]
)
return "\n".join(lines)
def main() -> int:
"""Write deterministic JSON and Markdown audit artifacts."""
audit = generate_audit()
JSON_OUTPUT.write_text(
json.dumps(audit, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
MARKDOWN_OUTPUT.write_text(render_markdown(audit), encoding="utf-8")
print(f"generated {JSON_OUTPUT.relative_to(PROJECT_ROOT)} and {MARKDOWN_OUTPUT.relative_to(PROJECT_ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())