#!/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())