266 lines
9.1 KiB
Python
266 lines
9.1 KiB
Python
"""Native vSphere API catalog for the Web UI console.
|
|
|
|
Parameter / request-body metadata comes from the official Automation OpenAPI
|
|
(``app/vsphere/rest/param_index.json``, generated by
|
|
``scripts/generate_vsphere_param_index.py``). Nested ``body_example`` values are
|
|
flattened into dotted PARAM leaves (``placement.host``, ``cpu.count``, …).
|
|
Path-parameter examples still use lab seed identifiers so Send works against
|
|
the seeded inventory.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from app.vsphere.contracts.matrix import (
|
|
VERSIONS,
|
|
catalog_entries_for_major,
|
|
is_implemented_for_major,
|
|
load_bundle,
|
|
)
|
|
from app.vsphere.rest.param_fields import body_fields_from_example, set_by_path
|
|
|
|
_PATH_PARAM = re.compile(r"\{([^{}/]+)\}")
|
|
_PARAM_INDEX_PATH = Path(__file__).resolve().parents[1] / "rest" / "param_index.json"
|
|
|
|
_PATH_EXAMPLES: dict[str, str] = {
|
|
"vm": "vm-101",
|
|
"host": "host-11",
|
|
"datastore": "datastore-31",
|
|
"task": "task-1",
|
|
"snapshot": "snapshot-1",
|
|
"category_id": "urn:vmomi:InventoryServiceCategory:demo:GLOBAL",
|
|
"tag_id": "urn:vmomi:InventoryServiceTag:demo:GLOBAL",
|
|
"item_id": "item-demo",
|
|
"folder": "group-v23",
|
|
"datacenter": "datacenter-21",
|
|
"cluster": "domain-c21",
|
|
"resource_pool": "resgroup-22",
|
|
"permission_id": "1",
|
|
"policy": "policy-default",
|
|
"library_id": "library-demo",
|
|
}
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _param_index() -> dict[str, Any]:
|
|
if not _PARAM_INDEX_PATH.is_file():
|
|
return {"methods": {}}
|
|
payload = json.loads(_PARAM_INDEX_PATH.read_text(encoding="utf-8"))
|
|
methods = payload.get("methods")
|
|
return methods if isinstance(methods, dict) else {}
|
|
|
|
|
|
def list_vsphere_majors(*, runtime_version: str | None) -> dict[str, Any]:
|
|
return {
|
|
"runtime_version": runtime_version or VERSIONS[9]["version"],
|
|
"plane": "vsphere-rest",
|
|
"majors": [
|
|
{
|
|
"major": major,
|
|
"series": meta["series"],
|
|
"latest_version": meta["version"],
|
|
"artifact_url": f"stub://vmware/vsphere-{meta['version']}/api-contract",
|
|
"bundled": True,
|
|
}
|
|
for major, meta in VERSIONS.items()
|
|
],
|
|
}
|
|
|
|
|
|
def vsphere_catalog_payload(major: int) -> dict[str, Any]:
|
|
meta = VERSIONS.get(major) or VERSIONS[9]
|
|
bundle = load_bundle(major)
|
|
entries = catalog_entries_for_major(major)
|
|
grouped: dict[str, dict[str, dict[str, Any]]] = {}
|
|
for entry in entries:
|
|
path = entry["path"]
|
|
parts = [p for p in path.split("/") if p]
|
|
tag = "/".join(parts[:3]) if len(parts) >= 3 else path
|
|
by_path = grouped.setdefault(tag, {})
|
|
path_entry = by_path.setdefault(path, {"path": path, "methods": []})
|
|
path_entry["methods"].append(
|
|
{
|
|
"verb": entry["verb"],
|
|
"name": f"{entry['verb'].lower()}_{parts[-1] if parts else 'root'}",
|
|
"description": f"{entry['status']} {entry['verb']} {path}",
|
|
"protected": True,
|
|
"implemented": entry["status"] in {"implemented", "stub"},
|
|
}
|
|
)
|
|
categories = [
|
|
{
|
|
"tag": tag,
|
|
"paths": sorted(by_path.values(), key=lambda item: item["path"]),
|
|
}
|
|
for tag, by_path in sorted(grouped.items())
|
|
]
|
|
return {
|
|
"major": major,
|
|
"series": meta["series"],
|
|
"source_version": meta["version"],
|
|
"latest_version": meta["version"],
|
|
"artifact_url": f"stub://vmware/vsphere-{meta['version']}/api-contract",
|
|
"bundled": True,
|
|
"path_count": sum(len(cat["paths"]) for cat in categories),
|
|
"method_count": len(entries),
|
|
"categories": categories,
|
|
"plane": "vsphere-rest",
|
|
"contract_kind": bundle.get("kind", "stub-openapi-matrix"),
|
|
}
|
|
|
|
|
|
def _field(
|
|
name: str,
|
|
*,
|
|
type_name: str = "string",
|
|
optional: bool = False,
|
|
example: Any = None,
|
|
description: str | None = None,
|
|
enum: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"name": name,
|
|
"type": type_name,
|
|
"description": description,
|
|
"optional": optional,
|
|
"enum": enum or [],
|
|
"example": example if example is not None else name,
|
|
}
|
|
|
|
|
|
def _path_fields(path: str) -> list[dict[str, Any]]:
|
|
fields = []
|
|
for name in _PATH_PARAM.findall(path):
|
|
fields.append(
|
|
_field(
|
|
name,
|
|
optional=False,
|
|
example=_PATH_EXAMPLES.get(name, name),
|
|
description=f"Path parameter {{{name}}}",
|
|
)
|
|
)
|
|
return fields
|
|
|
|
|
|
def _normalize_index_fields(raw_fields: Any) -> list[dict[str, Any]]:
|
|
if not isinstance(raw_fields, list):
|
|
return []
|
|
fields: list[dict[str, Any]] = []
|
|
for item in raw_fields:
|
|
if not isinstance(item, dict) or not item.get("name"):
|
|
continue
|
|
enum = item.get("enum") if isinstance(item.get("enum"), list) else []
|
|
fields.append(
|
|
_field(
|
|
str(item["name"]),
|
|
type_name=str(item.get("type") or "string"),
|
|
optional=bool(item.get("optional", True)),
|
|
example=item.get("example"),
|
|
description=item.get("description") if isinstance(item.get("description"), str) else None,
|
|
enum=[str(value) for value in enum],
|
|
)
|
|
)
|
|
return fields
|
|
|
|
|
|
def _lookup_param_entry(verb: str, path: str) -> dict[str, Any] | None:
|
|
methods = _param_index()
|
|
key = f"{verb.upper()} {path}"
|
|
entry = methods.get(key)
|
|
return entry if isinstance(entry, dict) else None
|
|
|
|
|
|
def vsphere_method_payload(
|
|
*,
|
|
major: int,
|
|
path: str,
|
|
verb: str,
|
|
runtime_version: str | None,
|
|
) -> dict[str, Any]:
|
|
meta = VERSIONS.get(major) or VERSIONS[9]
|
|
upper = verb.upper()
|
|
path_fields = _path_fields(path)
|
|
entry = _lookup_param_entry(upper, path)
|
|
|
|
query_fields: list[dict[str, Any]] = []
|
|
body_fields: list[dict[str, Any]] = []
|
|
body_example: dict[str, Any] = {}
|
|
|
|
if entry is not None:
|
|
# Prefer OpenAPI path examples when present, but keep lab seed IDs.
|
|
indexed_path = _normalize_index_fields(entry.get("path_fields"))
|
|
if indexed_path:
|
|
by_name = {field["name"]: field for field in indexed_path}
|
|
merged_path: list[dict[str, Any]] = []
|
|
for field in path_fields:
|
|
indexed = by_name.get(str(field["name"]))
|
|
if indexed is None:
|
|
merged_path.append(field)
|
|
continue
|
|
merged = dict(indexed)
|
|
# Lab seed identifiers beat generic OpenAPI "example" strings.
|
|
if field["name"] in _PATH_EXAMPLES:
|
|
merged["example"] = _PATH_EXAMPLES[str(field["name"])]
|
|
merged_path.append(merged)
|
|
path_fields = merged_path
|
|
query_fields = _normalize_index_fields(entry.get("query_fields"))
|
|
body_fields = _normalize_index_fields(entry.get("body_fields"))
|
|
raw_example = entry.get("body_example")
|
|
if isinstance(raw_example, dict):
|
|
body_example = raw_example
|
|
|
|
# Prefer leaf paths flattened from nested body_example (placement.host, …).
|
|
nested_fields = body_fields_from_example(body_example)
|
|
if nested_fields:
|
|
body_fields = nested_fields
|
|
elif not body_example and body_fields:
|
|
# Build a nested example from dotted / JSON-string body fields.
|
|
built: dict[str, Any] = {}
|
|
for field in body_fields:
|
|
if field.get("optional"):
|
|
continue
|
|
example = field.get("example")
|
|
name = str(field["name"])
|
|
if isinstance(example, str) and example[:1] in {"{", "["}:
|
|
try:
|
|
example = json.loads(example)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
if "." in name:
|
|
set_by_path(built, name, example)
|
|
else:
|
|
built[name] = example
|
|
body_example = built
|
|
nested_fields = body_fields_from_example(body_example)
|
|
if nested_fields:
|
|
body_fields = nested_fields
|
|
|
|
# Params drawer shows query + body together; keep query_fields distinct for URL build.
|
|
params_fields = [*body_fields, *query_fields]
|
|
|
|
resolved = path
|
|
for field in path_fields:
|
|
resolved = resolved.replace(f"{{{field['name']}}}", str(field["example"]))
|
|
return {
|
|
"major": major,
|
|
"path": path,
|
|
"verb": upper,
|
|
"name": path.strip("/").replace("/", "_"),
|
|
"description": f"{upper} {path}",
|
|
"resolved_path": resolved,
|
|
"path_fields": path_fields,
|
|
"query_fields": query_fields,
|
|
"body_fields": params_fields,
|
|
"indexed_fields": [],
|
|
"body_example": body_example,
|
|
"implemented": is_implemented_for_major(upper, path, major),
|
|
"runtime_version": runtime_version or meta["version"],
|
|
"source_version": meta["version"],
|
|
"param_source": "openapi" if entry is not None else "none",
|
|
}
|