mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-01 16:04:24 -08:00
feat: harden API safety and expand command docs with full test coverage
This commit is contained in:
447
scripts/generate_unraid_api_reference.py
Normal file
447
scripts/generate_unraid_api_reference.py
Normal file
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a complete Markdown reference from Unraid GraphQL introspection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
DEFAULT_OUTPUT = Path("docs/UNRAID_API_COMPLETE_REFERENCE.md")
|
||||
|
||||
INTROSPECTION_QUERY = """
|
||||
query FullIntrospection {
|
||||
__schema {
|
||||
queryType { name }
|
||||
mutationType { name }
|
||||
subscriptionType { name }
|
||||
directives {
|
||||
name
|
||||
description
|
||||
locations
|
||||
args {
|
||||
name
|
||||
description
|
||||
defaultValue
|
||||
type { ...TypeRef }
|
||||
}
|
||||
}
|
||||
types {
|
||||
kind
|
||||
name
|
||||
description
|
||||
fields(includeDeprecated: true) {
|
||||
name
|
||||
description
|
||||
isDeprecated
|
||||
deprecationReason
|
||||
args {
|
||||
name
|
||||
description
|
||||
defaultValue
|
||||
type { ...TypeRef }
|
||||
}
|
||||
type { ...TypeRef }
|
||||
}
|
||||
inputFields {
|
||||
name
|
||||
description
|
||||
defaultValue
|
||||
type { ...TypeRef }
|
||||
}
|
||||
interfaces { kind name }
|
||||
enumValues(includeDeprecated: true) {
|
||||
name
|
||||
description
|
||||
isDeprecated
|
||||
deprecationReason
|
||||
}
|
||||
possibleTypes { kind name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment TypeRef on __Type {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _clean(text: str | None) -> str:
|
||||
"""Collapse multiline description text into a single line."""
|
||||
if not text:
|
||||
return ""
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _type_to_str(type_ref: dict[str, Any] | None) -> str:
|
||||
"""Render GraphQL nested type refs to SDL-like notation."""
|
||||
if not type_ref:
|
||||
return "Unknown"
|
||||
kind = type_ref.get("kind")
|
||||
if kind == "NON_NULL":
|
||||
return f"{_type_to_str(type_ref.get('ofType'))}!"
|
||||
if kind == "LIST":
|
||||
return f"[{_type_to_str(type_ref.get('ofType'))}]"
|
||||
return str(type_ref.get("name") or kind or "Unknown")
|
||||
|
||||
|
||||
def _field_lines(field: dict[str, Any], *, is_input: bool) -> list[str]:
|
||||
"""Render field/input-field markdown lines."""
|
||||
lines: list[str] = []
|
||||
lines.append(f"- `{field['name']}`: `{_type_to_str(field.get('type'))}`")
|
||||
|
||||
description = _clean(field.get("description"))
|
||||
if description:
|
||||
lines.append(f" - {description}")
|
||||
|
||||
default_value = field.get("defaultValue")
|
||||
if default_value is not None:
|
||||
lines.append(f" - Default: `{default_value}`")
|
||||
|
||||
if not is_input:
|
||||
args = sorted(field.get("args") or [], key=lambda item: str(item["name"]))
|
||||
if args:
|
||||
lines.append(" - Arguments:")
|
||||
for arg in args:
|
||||
arg_line = f" - `{arg['name']}`: `{_type_to_str(arg.get('type'))}`"
|
||||
if arg.get("defaultValue") is not None:
|
||||
arg_line += f" (default: `{arg['defaultValue']}`)"
|
||||
lines.append(arg_line)
|
||||
|
||||
arg_description = _clean(arg.get("description"))
|
||||
if arg_description:
|
||||
lines.append(f" - {arg_description}")
|
||||
|
||||
if field.get("isDeprecated"):
|
||||
reason = _clean(field.get("deprecationReason"))
|
||||
lines.append(f" - Deprecated: {reason}" if reason else " - Deprecated")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _build_markdown(schema: dict[str, Any], *, include_introspection: bool) -> str:
|
||||
"""Build full Markdown schema reference."""
|
||||
all_types = schema.get("types") or []
|
||||
types = [
|
||||
item
|
||||
for item in all_types
|
||||
if item.get("name") and (include_introspection or not str(item["name"]).startswith("__"))
|
||||
]
|
||||
types_by_name = {str(item["name"]): item for item in types}
|
||||
|
||||
kind_counts = Counter(str(item.get("kind", "UNKNOWN")) for item in types)
|
||||
directives = sorted(schema.get("directives") or [], key=lambda item: str(item["name"]))
|
||||
|
||||
implements_map: dict[str, list[str]] = defaultdict(list)
|
||||
for item in types:
|
||||
for interface in item.get("interfaces") or []:
|
||||
interface_name = interface.get("name")
|
||||
if interface_name:
|
||||
implements_map[str(interface_name)].append(str(item["name"]))
|
||||
|
||||
query_root = (schema.get("queryType") or {}).get("name")
|
||||
mutation_root = (schema.get("mutationType") or {}).get("name")
|
||||
subscription_root = (schema.get("subscriptionType") or {}).get("name")
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# Unraid GraphQL API Complete Schema Reference")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Generated via live GraphQL introspection for the configured endpoint and API key."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("This is permission-scoped: it contains everything visible to the API key used.")
|
||||
lines.append("")
|
||||
lines.append("## Table of Contents")
|
||||
lines.append("- [Schema Summary](#schema-summary)")
|
||||
lines.append("- [Root Operations](#root-operations)")
|
||||
lines.append("- [Directives](#directives)")
|
||||
lines.append("- [All Types (Alphabetical)](#all-types-alphabetical)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Schema Summary")
|
||||
lines.append(f"- Query root: `{query_root}`")
|
||||
lines.append(f"- Mutation root: `{mutation_root}`")
|
||||
lines.append(f"- Subscription root: `{subscription_root}`")
|
||||
lines.append(f"- Total types: **{len(types)}**")
|
||||
lines.append(f"- Total directives: **{len(directives)}**")
|
||||
lines.append("- Type kinds:")
|
||||
lines.extend(f"- `{kind}`: {kind_counts[kind]}" for kind in sorted(kind_counts))
|
||||
lines.append("")
|
||||
|
||||
def render_root(root_name: str | None, label: str) -> None:
|
||||
lines.append(f"### {label}")
|
||||
if not root_name or root_name not in types_by_name:
|
||||
lines.append("Not exposed.")
|
||||
lines.append("")
|
||||
return
|
||||
|
||||
root_type = types_by_name[root_name]
|
||||
fields = sorted(root_type.get("fields") or [], key=lambda item: str(item["name"]))
|
||||
lines.append(f"Total fields: **{len(fields)}**")
|
||||
lines.append("")
|
||||
for field in fields:
|
||||
args = sorted(field.get("args") or [], key=lambda item: str(item["name"]))
|
||||
arg_signature: list[str] = []
|
||||
for arg in args:
|
||||
part = f"{arg['name']}: {_type_to_str(arg.get('type'))}"
|
||||
if arg.get("defaultValue") is not None:
|
||||
part += f" = {arg['defaultValue']}"
|
||||
arg_signature.append(part)
|
||||
|
||||
signature = (
|
||||
f"{field['name']}({', '.join(arg_signature)})"
|
||||
if arg_signature
|
||||
else f"{field['name']}()"
|
||||
)
|
||||
lines.append(f"- `{signature}: {_type_to_str(field.get('type'))}`")
|
||||
|
||||
description = _clean(field.get("description"))
|
||||
if description:
|
||||
lines.append(f" - {description}")
|
||||
|
||||
if field.get("isDeprecated"):
|
||||
reason = _clean(field.get("deprecationReason"))
|
||||
lines.append(f" - Deprecated: {reason}" if reason else " - Deprecated")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Root Operations")
|
||||
render_root(query_root, "Queries")
|
||||
render_root(mutation_root, "Mutations")
|
||||
render_root(subscription_root, "Subscriptions")
|
||||
|
||||
lines.append("## Directives")
|
||||
if not directives:
|
||||
lines.append("No directives exposed.")
|
||||
lines.append("")
|
||||
else:
|
||||
for directive in directives:
|
||||
lines.append(f"### `@{directive['name']}`")
|
||||
description = _clean(directive.get("description"))
|
||||
if description:
|
||||
lines.append(description)
|
||||
lines.append("")
|
||||
locations = directive.get("locations") or []
|
||||
lines.append(
|
||||
f"- Locations: {', '.join(f'`{item}`' for item in locations) if locations else 'None'}"
|
||||
)
|
||||
args = sorted(directive.get("args") or [], key=lambda item: str(item["name"]))
|
||||
if args:
|
||||
lines.append("- Arguments:")
|
||||
for arg in args:
|
||||
line = f" - `{arg['name']}`: `{_type_to_str(arg.get('type'))}`"
|
||||
if arg.get("defaultValue") is not None:
|
||||
line += f" (default: `{arg['defaultValue']}`)"
|
||||
lines.append(line)
|
||||
arg_description = _clean(arg.get("description"))
|
||||
if arg_description:
|
||||
lines.append(f" - {arg_description}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## All Types (Alphabetical)")
|
||||
for item in sorted(types, key=lambda row: str(row["name"])):
|
||||
name = str(item["name"])
|
||||
kind = str(item["kind"])
|
||||
lines.append(f"### `{name}` ({kind})")
|
||||
|
||||
description = _clean(item.get("description"))
|
||||
if description:
|
||||
lines.append(description)
|
||||
lines.append("")
|
||||
|
||||
if kind == "OBJECT":
|
||||
interfaces = sorted(
|
||||
str(interface["name"])
|
||||
for interface in (item.get("interfaces") or [])
|
||||
if interface.get("name")
|
||||
)
|
||||
if interfaces:
|
||||
lines.append(f"- Implements: {', '.join(f'`{value}`' for value in interfaces)}")
|
||||
|
||||
fields = sorted(item.get("fields") or [], key=lambda row: str(row["name"]))
|
||||
lines.append(f"- Fields ({len(fields)}):")
|
||||
if fields:
|
||||
for field in fields:
|
||||
lines.extend(_field_lines(field, is_input=False))
|
||||
else:
|
||||
lines.append("- None")
|
||||
|
||||
elif kind == "INPUT_OBJECT":
|
||||
fields = sorted(item.get("inputFields") or [], key=lambda row: str(row["name"]))
|
||||
lines.append(f"- Input fields ({len(fields)}):")
|
||||
if fields:
|
||||
for field in fields:
|
||||
lines.extend(_field_lines(field, is_input=True))
|
||||
else:
|
||||
lines.append("- None")
|
||||
|
||||
elif kind == "ENUM":
|
||||
enum_values = sorted(item.get("enumValues") or [], key=lambda row: str(row["name"]))
|
||||
lines.append(f"- Enum values ({len(enum_values)}):")
|
||||
if enum_values:
|
||||
for enum_value in enum_values:
|
||||
lines.append(f" - `{enum_value['name']}`")
|
||||
enum_description = _clean(enum_value.get("description"))
|
||||
if enum_description:
|
||||
lines.append(f" - {enum_description}")
|
||||
if enum_value.get("isDeprecated"):
|
||||
reason = _clean(enum_value.get("deprecationReason"))
|
||||
lines.append(
|
||||
f" - Deprecated: {reason}" if reason else " - Deprecated"
|
||||
)
|
||||
else:
|
||||
lines.append("- None")
|
||||
|
||||
elif kind == "INTERFACE":
|
||||
fields = sorted(item.get("fields") or [], key=lambda row: str(row["name"]))
|
||||
lines.append(f"- Interface fields ({len(fields)}):")
|
||||
if fields:
|
||||
for field in fields:
|
||||
lines.extend(_field_lines(field, is_input=False))
|
||||
else:
|
||||
lines.append("- None")
|
||||
|
||||
implementers = sorted(implements_map.get(name, []))
|
||||
if implementers:
|
||||
lines.append(
|
||||
f"- Implemented by ({len(implementers)}): "
|
||||
+ ", ".join(f"`{value}`" for value in implementers)
|
||||
)
|
||||
else:
|
||||
lines.append("- Implemented by (0): None")
|
||||
|
||||
elif kind == "UNION":
|
||||
possible_types = sorted(
|
||||
str(possible["name"])
|
||||
for possible in (item.get("possibleTypes") or [])
|
||||
if possible.get("name")
|
||||
)
|
||||
if possible_types:
|
||||
lines.append(
|
||||
f"- Possible types ({len(possible_types)}): "
|
||||
+ ", ".join(f"`{value}`" for value in possible_types)
|
||||
)
|
||||
else:
|
||||
lines.append("- Possible types (0): None")
|
||||
|
||||
elif kind == "SCALAR":
|
||||
lines.append("- Scalar type")
|
||||
|
||||
else:
|
||||
lines.append("- Unhandled type kind")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
"""Parse CLI args."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate complete Unraid GraphQL schema reference Markdown from introspection."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-url",
|
||||
default=os.getenv("UNRAID_API_URL", ""),
|
||||
help="GraphQL endpoint URL (default: UNRAID_API_URL env var).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key",
|
||||
default=os.getenv("UNRAID_API_KEY", ""),
|
||||
help="API key (default: UNRAID_API_KEY env var).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=DEFAULT_OUTPUT,
|
||||
help=f"Output markdown file path (default: {DEFAULT_OUTPUT}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout-seconds",
|
||||
type=float,
|
||||
default=90.0,
|
||||
help="HTTP timeout in seconds (default: 90).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verify-ssl",
|
||||
action="store_true",
|
||||
help="Enable SSL cert verification. Default is disabled for local/self-signed setups.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-introspection-types",
|
||||
action="store_true",
|
||||
help="Include __Schema/__Type/etc in the generated type list.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run generator CLI."""
|
||||
args = _parse_args()
|
||||
|
||||
if not args.api_url:
|
||||
raise SystemExit("Missing API URL. Provide --api-url or set UNRAID_API_URL.")
|
||||
if not args.api_key:
|
||||
raise SystemExit("Missing API key. Provide --api-key or set UNRAID_API_KEY.")
|
||||
|
||||
headers = {"Authorization": f"Bearer {args.api_key}", "Content-Type": "application/json"}
|
||||
|
||||
with httpx.Client(timeout=args.timeout_seconds, verify=args.verify_ssl) as client:
|
||||
response = client.post(args.api_url, json={"query": INTROSPECTION_QUERY}, headers=headers)
|
||||
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if payload.get("errors"):
|
||||
errors = json.dumps(payload["errors"], indent=2)
|
||||
raise SystemExit(f"GraphQL introspection returned errors:\n{errors}")
|
||||
|
||||
schema = (payload.get("data") or {}).get("__schema")
|
||||
if not schema:
|
||||
raise SystemExit("GraphQL introspection returned no __schema payload.")
|
||||
|
||||
markdown = _build_markdown(schema, include_introspection=bool(args.include_introspection_types))
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(markdown, encoding="utf-8")
|
||||
|
||||
print(f"Wrote {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user