Files
vmware-api-simulator/scripts/generate_vsphere_param_index.py
T

516 lines
18 KiB
Python

#!/usr/bin/env python3
"""Build a compact vSphere REST param index from official Automation OpenAPI.
Source (default):
https://raw.githubusercontent.com/vmware/vcf-api-specs/main/specifications/vsphere/openapi/automation/vcenter.yaml
Output:
app/vsphere/rest/param_index.json
Keys are ``VERB /api/...`` matching the simulator catalog (OpenAPI servers already
use ``/api`` as the base). Paths that OpenAPI encodes as
``/vcenter/vm/{vm}/power?action=start`` are collapsed onto the template path with
an ``action`` query field (enum of all observed actions).
Requires PyYAML at generation time only (not a runtime dependency).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.request
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.vsphere.rest.param_fields import body_fields_from_example # noqa: E402
DEFAULT_URL = (
"https://raw.githubusercontent.com/vmware/vcf-api-specs/main/"
"specifications/vsphere/openapi/automation/vcenter.yaml"
)
OUT = ROOT / "app" / "vsphere" / "rest" / "param_index.json"
_PATH_QUERY = re.compile(r"^(?P<path>[^?]+)(?:\?(?P<query>.*))?$")
_ENUM_CAP = 24
# Prefer lab seed identifiers when property names match.
# Filter examples must AND-match on every profile (small / large / demo-cluster):
# web-01 (vm-101) is always POWERED_ON on host-11.
_LAB_EXAMPLES: dict[str, Any] = {
"vm": "vm-101",
"vms": ["vm-101"],
"name": "web-01",
"names": ["web-01"],
"host": "host-11",
"hosts": ["host-11"],
"folder": "group-v23",
"folders": ["group-v23"],
"datastore": "datastore-31",
"datastores": ["datastore-31"],
"datacenter": "datacenter-21",
"datacenters": ["datacenter-21"],
"cluster": "domain-c21",
"clusters": ["domain-c21"],
"resource_pool": "resgroup-22",
"resource_pools": ["resgroup-22"],
"network": "network-41",
"networks": ["network-41"],
"power_states": ["POWERED_ON"],
"guest_os": "OTHER_GUEST_64",
"guest_OS": "OTHER_GUEST_64",
"action": "start",
"category_id": "urn:vmomi:InventoryServiceCategory:demo:GLOBAL",
"tag_id": "urn:vmomi:InventoryServiceTag:demo:GLOBAL",
"item_id": "item-demo",
"library_id": "library-demo",
"task": "task-1",
"snapshot": "snapshot-1",
"count": 2,
"size_mib": 2048,
"size_MiB": 2048,
"memory_size_MiB": 2048,
"cpu_count": 2,
}
def _load_yaml(path: Path) -> dict[str, Any]:
try:
import yaml
except ImportError as exc: # pragma: no cover
raise SystemExit("PyYAML is required to generate the param index") from exc
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise SystemExit("OpenAPI root must be a mapping")
return data
def _fetch(url: str, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
print(f"Fetching {url}", file=sys.stderr)
with urllib.request.urlopen(url, timeout=120) as resp: # noqa: S310
dest.write_bytes(resp.read())
print(f"Wrote {dest} ({dest.stat().st_size} bytes)", file=sys.stderr)
def _resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]:
if not ref.startswith("#/"):
return {}
node: Any = spec
for part in ref[2:].split("/"):
if not isinstance(node, dict):
return {}
node = node.get(part)
return node if isinstance(node, dict) else {}
def _deref(spec: dict[str, Any], schema: dict[str, Any] | None) -> dict[str, Any]:
if not schema:
return {}
if "$ref" in schema:
resolved = _resolve_ref(spec, str(schema["$ref"]))
merged = dict(resolved)
for key, value in schema.items():
if key != "$ref":
merged[key] = value
return _deref(spec, merged) if ("$ref" in merged or "allOf" in merged) else merged
if "allOf" in schema and isinstance(schema["allOf"], list):
merged: dict[str, Any] = {}
props: dict[str, Any] = {}
required: list[str] = []
for part in schema["allOf"]:
if not isinstance(part, dict):
continue
resolved = _deref(spec, part)
for key, value in resolved.items():
if key == "properties" and isinstance(value, dict):
props.update(value)
elif key == "required" and isinstance(value, list):
required.extend(str(item) for item in value)
elif key not in {"properties", "required"}:
merged[key] = value
for key, value in schema.items():
if key == "allOf":
continue
if key == "properties" and isinstance(value, dict):
props.update(value)
elif key == "required" and isinstance(value, list):
required.extend(str(item) for item in value)
else:
merged[key] = value
if props:
merged["properties"] = props
if required:
merged["required"] = list(dict.fromkeys(required))
if "type" not in merged and props:
merged["type"] = "object"
return merged
return schema
def _short_desc(text: Any) -> str | None:
if not isinstance(text, str) or not text.strip():
return None
line = text.strip().split("\n", 1)[0].strip()
if len(line) > 160:
return line[:157] + "..."
return line
def _example_for(
spec: dict[str, Any],
name: str,
schema: dict[str, Any],
*,
depth: int = 0,
) -> Any:
schema = _deref(spec, schema)
if name in _LAB_EXAMPLES:
return _LAB_EXAMPLES[name]
if "example" in schema:
return schema["example"]
if "default" in schema:
return schema["default"]
enum = schema.get("enum")
if isinstance(enum, list) and enum:
return enum[0]
typ = schema.get("type")
if typ == "array":
items = schema.get("items") if isinstance(schema.get("items"), dict) else {}
item_ex = _example_for(spec, name.rstrip("s") or name, items, depth=depth + 1)
return [item_ex] if item_ex is not None else []
if typ == "object" or "properties" in schema:
if depth >= 3:
return {}
props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
req = set(schema.get("required") or [])
# Prefer required props; include a few well-known optional lab fields.
keys = list(req)
for extra in ("name", "placement", "cpu", "memory", "description", "spec", "create_spec"):
if extra in props and extra not in keys:
keys.append(extra)
if not keys:
keys = list(props.keys())[:6]
out: dict[str, Any] = {}
for key in keys:
prop = props.get(key)
if not isinstance(prop, dict):
continue
out[key] = _example_for(spec, key, prop, depth=depth + 1)
return out
if typ == "boolean":
return False
if typ == "integer":
return int(schema["minimum"]) if isinstance(schema.get("minimum"), (int, float)) else 1
if typ == "number":
return float(schema["minimum"]) if isinstance(schema.get("minimum"), (int, float)) else 1.0
if typ == "string" or typ is None:
return "example"
return None
def _field_from_schema(
spec: dict[str, Any],
name: str,
schema: dict[str, Any],
*,
optional: bool,
description: str | None = None,
) -> dict[str, Any]:
schema = _deref(spec, schema)
typ = schema.get("type")
if typ is None and "properties" in schema:
typ = "object"
if typ is None and "items" in schema:
typ = "array"
enum = schema.get("enum") if isinstance(schema.get("enum"), list) else []
if len(enum) > _ENUM_CAP:
enum = enum[:_ENUM_CAP]
example = _example_for(spec, name, schema)
return {
"name": name,
"type": typ or "string",
"optional": optional,
"description": _short_desc(description or schema.get("description")),
"enum": enum,
"example": example,
}
def _collect_params(
spec: dict[str, Any],
operation: dict[str, Any],
path_item_params: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
query: list[dict[str, Any]] = []
path: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for raw in list(path_item_params) + list(operation.get("parameters") or []):
if not isinstance(raw, dict):
continue
param = _deref(spec, raw) if "$ref" in raw else raw
name = str(param.get("name") or "")
location = str(param.get("in") or "")
if not name or location not in {"query", "path"}:
continue
key = (location, name)
if key in seen:
continue
seen.add(key)
schema = param.get("schema") if isinstance(param.get("schema"), dict) else {}
field = _field_from_schema(
spec,
name,
schema,
optional=not bool(param.get("required")),
description=param.get("description") if isinstance(param.get("description"), str) else None,
)
if location == "query":
query.append(field)
else:
path.append(field)
return query, path
def _body_from_operation(
spec: dict[str, Any],
operation: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
request_body = operation.get("requestBody")
if not isinstance(request_body, dict):
return [], {}
request_body = _deref(spec, request_body) if "$ref" in request_body else request_body
content = request_body.get("content") if isinstance(request_body.get("content"), dict) else {}
media = content.get("application/json") or next(iter(content.values()), None)
if not isinstance(media, dict):
return [], {}
schema = _deref(spec, media.get("schema") if isinstance(media.get("schema"), dict) else {})
if not schema:
return [], {}
props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
if props:
example = _example_for(spec, "", schema)
example_dict = example if isinstance(example, dict) else {}
# PARAM drawer leaves come from nested body_example (placement.host, …).
return body_fields_from_example(example_dict), example_dict
# Body is a naked $ref / non-object — still emit an example blob.
example = _example_for(spec, "body", schema)
example_dict = example if isinstance(example, dict) else {}
return body_fields_from_example(example_dict), example_dict
def _normalize_path_key(raw_path: str) -> tuple[str, dict[str, str]]:
"""Return (/api/... template, query extras from path key like action=start)."""
match = _PATH_QUERY.match(raw_path)
if not match:
return raw_path, {}
path = match.group("path")
query_raw = match.group("query") or ""
extras: dict[str, str] = {}
if query_raw:
for part in query_raw.split("&"):
if "=" in part:
key, value = part.split("=", 1)
extras[key] = value
elif part:
extras[part] = ""
if not path.startswith("/api/"):
path = "/api" + path if path.startswith("/") else "/api/" + path
return path, extras
def _merge_action_field(query_fields: list[dict[str, Any]], actions: set[str]) -> None:
if not actions:
return
existing = next((field for field in query_fields if field["name"] == "action"), None)
ordered = sorted(actions)
if existing is None:
query_fields.insert(
0,
{
"name": "action",
"type": "string",
"optional": False,
"description": "Operation action query parameter",
"enum": ordered,
"example": ordered[0],
},
)
return
enum = list(dict.fromkeys([*list(existing.get("enum") or []), *ordered]))
existing["enum"] = enum
existing["optional"] = False
if not existing.get("example"):
existing["example"] = enum[0]
def build_index(spec: dict[str, Any]) -> dict[str, Any]:
paths = spec.get("paths") if isinstance(spec.get("paths"), dict) else {}
# Accumulate by VERB + template path so ?action= variants collapse.
buckets: dict[str, dict[str, Any]] = {}
def ensure_bucket(verb: str, template: str) -> dict[str, Any]:
key = f"{verb} {template}"
return buckets.setdefault(
key,
{
"verb": verb,
"path": template,
"query_fields": [],
"path_fields": [],
"body_fields": [],
"body_example": {},
"operation_ids": [],
"actions": set(),
},
)
def merge_operation(
*,
verb: str,
template: str,
operation: dict[str, Any],
path_params: list[dict[str, Any]],
action: str | None = None,
) -> None:
key = f"{verb} {template}"
if action:
existing = buckets.get(key)
# Do not pollute CreateSpec-style POSTs with clone/register actions.
if existing is not None and (existing["body_fields"] or existing["body_example"]):
return
bucket = ensure_bucket(verb, template)
bucket["actions"].add(action)
_query, path_fields = _collect_params(spec, operation, path_params)
for field in path_fields:
names = {item["name"] for item in bucket["path_fields"]}
if field["name"] not in names:
bucket["path_fields"].append(field)
op_id = operation.get("operationId")
if isinstance(op_id, str) and op_id not in bucket["operation_ids"]:
bucket["operation_ids"].append(op_id)
return
bucket = ensure_bucket(verb, template)
query_fields, path_fields = _collect_params(spec, operation, path_params)
body_fields, body_example = _body_from_operation(spec, operation)
for field in query_fields:
names = {item["name"] for item in bucket["query_fields"]}
if field["name"] not in names:
bucket["query_fields"].append(field)
for field in path_fields:
names = {item["name"] for item in bucket["path_fields"]}
if field["name"] not in names:
bucket["path_fields"].append(field)
if body_fields and not bucket["body_fields"]:
bucket["body_fields"] = body_fields
if body_example and not bucket["body_example"]:
bucket["body_example"] = body_example
op_id = operation.get("operationId")
if isinstance(op_id, str) and op_id not in bucket["operation_ids"]:
bucket["operation_ids"].append(op_id)
# Pass 1: concrete paths without ?query — establish CreateSpec etc.
# Pass 2: ?action= variants — attach only onto action-style endpoints.
ordered_paths = sorted(paths.items(), key=lambda item: ("?" in str(item[0]), str(item[0])))
for raw_path, path_item in ordered_paths:
if not isinstance(path_item, dict):
continue
template, extras = _normalize_path_key(str(raw_path))
path_params = [p for p in (path_item.get("parameters") or []) if isinstance(p, dict)]
for verb, operation in path_item.items():
if verb.startswith("x-") or verb == "parameters" or not isinstance(operation, dict):
continue
upper = verb.upper()
if upper not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}:
continue
merge_operation(
verb=upper,
template=template,
operation=operation,
path_params=path_params,
action=extras.get("action") or None,
)
methods: dict[str, Any] = {}
for key, bucket in sorted(buckets.items()):
actions: set[str] = bucket.pop("actions")
_merge_action_field(bucket["query_fields"], actions)
entry = {
"verb": bucket["verb"],
"path": bucket["path"],
"query_fields": bucket["query_fields"],
"path_fields": bucket["path_fields"],
"body_fields": bucket["body_fields"],
"body_example": bucket["body_example"],
"operation_ids": bucket["operation_ids"],
}
methods[key] = entry
info = spec.get("info") if isinstance(spec.get("info"), dict) else {}
return {
"source": "vmware/vcf-api-specs specifications/vsphere/openapi/automation/vcenter.yaml",
"openapi": spec.get("openapi"),
"title": info.get("title"),
"version": info.get("version"),
"method_count": len(methods),
"methods": methods,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default=DEFAULT_URL, help="OpenAPI YAML URL")
parser.add_argument(
"--input",
type=Path,
help="Local OpenAPI YAML (skips download)",
)
parser.add_argument(
"--cache",
type=Path,
default=ROOT / ".cache" / "vcenter.openapi.yaml",
help="Download cache path",
)
parser.add_argument("--output", type=Path, default=OUT)
args = parser.parse_args()
if args.input is not None:
source = args.input
else:
if not args.cache.exists():
_fetch(args.url, args.cache)
source = args.cache
spec = _load_yaml(source)
index = build_index(spec)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(index, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(
f"Wrote {args.output} methods={index['method_count']} version={index.get('version')}",
file=sys.stderr,
)
# Spot-check critical routes.
for probe in ("GET /api/vcenter/vm", "POST /api/vcenter/vm", "POST /api/vcenter/vm/{vm}/power"):
entry = index["methods"].get(probe)
if not entry:
print(f"WARN missing {probe}", file=sys.stderr)
continue
print(
f"OK {probe} query={len(entry['query_fields'])} "
f"body_fields={len(entry['body_fields'])} "
f"body_example_keys={list(entry['body_example'])[:6]}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())