#!/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[^?]+)(?:\?(?P.*))?$") _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())