Add OpenStack request-body schemas and nested console PARAM sync.
This commit is contained in:
@@ -60,9 +60,9 @@ def _crud(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Expand a resource into list/detail/create/show/update/delete + actions/nested."""
|
||||
|
||||
singular = key[:-1] if key.endswith("s") and not key.endswith("ss") else key
|
||||
if key.endswith("ies"):
|
||||
singular = key[:-3] + "y"
|
||||
from app.openstack.singular import singular as _singularize
|
||||
|
||||
singular = _singularize(key)
|
||||
ops: list[dict[str, Any]] = [
|
||||
{
|
||||
"operation_id": f"{resource}_list",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate contracts/openstack/request_bodies/*.json for all pack write ops."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
from os_api_inventory.request_body_catalog import schema_for_operation # noqa: E402
|
||||
|
||||
SERIES_DEFAULT = "dalmatian"
|
||||
OUT_DIR = ROOT / "contracts" / "openstack" / "request_bodies"
|
||||
|
||||
|
||||
def _load_ops(series: str) -> dict[str, list[dict]]:
|
||||
series_dir = ROOT / "contracts" / "openstack" / series
|
||||
by_service: dict[str, list[dict]] = {}
|
||||
for api in sorted(series_dir.glob("*/api.json")):
|
||||
data = json.loads(api.read_text())
|
||||
service = str(data["service"])
|
||||
writes = [
|
||||
op
|
||||
for op in data.get("operations") or []
|
||||
if op.get("method") in {"POST", "PUT", "PATCH"}
|
||||
]
|
||||
by_service[service] = writes
|
||||
return by_service
|
||||
|
||||
|
||||
def generate(series: str, out_dir: Path) -> dict[str, int]:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
counts: dict[str, int] = {}
|
||||
# Merge ops across series for max coverage of operation_ids.
|
||||
merged: dict[str, dict[str, dict]] = defaultdict(dict)
|
||||
for series_name in ("yoga", "antelope", "caracal", "dalmatian"):
|
||||
if series and series != "all" and series_name != series:
|
||||
continue
|
||||
for service, ops in _load_ops(series_name).items():
|
||||
for op in ops:
|
||||
oid = op["operation_id"]
|
||||
path_key = f"{op['method']} {op['path']}"
|
||||
merged[service][oid] = op
|
||||
# also keep path index later
|
||||
_ = path_key
|
||||
|
||||
for service, by_oid in sorted(merged.items()):
|
||||
operations: dict[str, dict] = {}
|
||||
by_path: dict[str, dict] = {}
|
||||
for oid, op in sorted(by_oid.items()):
|
||||
schema = schema_for_operation(op)
|
||||
operations[oid] = schema
|
||||
by_path[f"{op['method']} {op['path']}"] = schema
|
||||
payload = {
|
||||
"service": service,
|
||||
"source": "generated-from-api-ref-catalog",
|
||||
"operations": operations,
|
||||
"by_path": by_path,
|
||||
}
|
||||
path = out_dir / f"{service}.json"
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
counts[service] = len(operations)
|
||||
return counts
|
||||
|
||||
|
||||
def coverage_report(series: str) -> int:
|
||||
from app.openstack.contract_loader import load_series_pack
|
||||
from app.openstack.request_bodies import clear_request_body_cache, missing_write_schemas
|
||||
|
||||
clear_request_body_cache()
|
||||
packs = load_series_pack(series)
|
||||
missing = missing_write_schemas(packs)
|
||||
print(f"series={series} missing={len(missing)}")
|
||||
for service, method, path, oid in missing[:50]:
|
||||
print(f" {service} {method} {path} ({oid})")
|
||||
if len(missing) > 50:
|
||||
print(f" ... and {len(missing) - 50} more")
|
||||
return len(missing)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--series", default="all", help="Series to scan or 'all'")
|
||||
parser.add_argument("--coverage", action="store_true", help="Report missing schemas only")
|
||||
parser.add_argument(
|
||||
"--coverage-series",
|
||||
default=SERIES_DEFAULT,
|
||||
help="Series for coverage check (default: dalmatian)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.coverage:
|
||||
missing = coverage_report(args.coverage_series)
|
||||
return 1 if missing else 0
|
||||
series = None if args.series == "all" else args.series
|
||||
counts = generate(series or "all", OUT_DIR)
|
||||
total = sum(counts.values())
|
||||
print(f"Wrote {len(counts)} services, {total} operation schemas → {OUT_DIR}")
|
||||
for name, count in sorted(counts.items()):
|
||||
print(f" {name}: {count}")
|
||||
if args.series == "all":
|
||||
# Coverage check requires the project venv (Python 3.13 dataclasses).
|
||||
print("Run coverage with: python tools/os_api_inventory/generate_request_bodies.py --coverage")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import requestBody schemas from gtema/openstack-openapi into request_bodies/.
|
||||
|
||||
Discovers versioned OpenAPI YAML under ``specs/<service>/`` and merges request
|
||||
schemas into ``contracts/openstack/request_bodies/<service>.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OUT_DIR = ROOT / "contracts" / "openstack" / "request_bodies"
|
||||
|
||||
SERVICE_MAP = {
|
||||
"compute": "nova",
|
||||
"network": "neutron",
|
||||
"identity": "keystone",
|
||||
"image": "glance",
|
||||
"block-storage": "cinder",
|
||||
"load-balancing": "octavia",
|
||||
"object-store": "swift",
|
||||
"placement": "placement",
|
||||
}
|
||||
|
||||
API_CONTENTS = (
|
||||
"https://api.github.com/repos/gtema/openstack-openapi/contents/specs/{svc}?ref=main"
|
||||
)
|
||||
|
||||
|
||||
def _load_yaml(text: str) -> dict[str, Any]:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit("PyYAML is required: pip install pyyaml") from exc
|
||||
data = yaml.safe_load(text)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("OpenAPI root must be a mapping")
|
||||
return data
|
||||
|
||||
|
||||
def _http_json(url: str) -> Any:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def _http_text(url: str, *, attempts: int = 3) -> str:
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=180) as resp:
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
block = resp.read(1024 * 1024)
|
||||
if not block:
|
||||
break
|
||||
chunks.append(block)
|
||||
return b"".join(chunks).decode()
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
print(f" download attempt {attempt}/{attempts} failed: {exc}")
|
||||
if last_error is None:
|
||||
raise RuntimeError("download failed without error")
|
||||
raise last_error
|
||||
|
||||
|
||||
def _pick_spec_url(openapi_name: str) -> str:
|
||||
entries = _http_json(API_CONTENTS.format(svc=openapi_name))
|
||||
yaml_files = [
|
||||
item
|
||||
for item in entries
|
||||
if item.get("type") == "file" and str(item.get("name", "")).endswith(".yaml")
|
||||
]
|
||||
if not yaml_files:
|
||||
raise FileNotFoundError(f"No OpenAPI YAML under specs/{openapi_name}")
|
||||
|
||||
def sort_key(item: dict[str, Any]) -> tuple[int, ...]:
|
||||
name = str(item["name"])
|
||||
nums = [int(x) for x in re.findall(r"\d+", name)]
|
||||
return tuple(nums) if nums else (0,)
|
||||
|
||||
# Prefer the highest versioned file (e.g. v2.96.yaml over v2.yaml).
|
||||
best = sorted(yaml_files, key=sort_key)[-1]
|
||||
url = best.get("download_url")
|
||||
if not url:
|
||||
raise FileNotFoundError(best)
|
||||
return str(url)
|
||||
|
||||
|
||||
def _resolve_ref(doc: dict[str, Any], node: Any) -> Any:
|
||||
if not isinstance(node, dict):
|
||||
return node
|
||||
ref = node.get("$ref")
|
||||
if not isinstance(ref, str) or not ref.startswith("#/"):
|
||||
return {k: _resolve_ref(doc, v) for k, v in node.items() if k != "$ref"}
|
||||
cursor: Any = doc
|
||||
for part in ref[2:].split("/"):
|
||||
part = part.replace("~1", "/").replace("~0", "~")
|
||||
cursor = cursor[part]
|
||||
return _resolve_ref(doc, cursor)
|
||||
|
||||
|
||||
def _request_schema(doc: dict[str, Any], operation: dict[str, Any]) -> dict[str, Any] | None:
|
||||
body = operation.get("requestBody")
|
||||
if not body:
|
||||
return None
|
||||
body = _resolve_ref(doc, body)
|
||||
content = body.get("content") or {}
|
||||
for key in ("application/json", "application/openstack-images-v2.1-json-patch"):
|
||||
if key in content:
|
||||
media = _resolve_ref(doc, content[key])
|
||||
schema = media.get("schema")
|
||||
if schema:
|
||||
return _resolve_ref(doc, schema)
|
||||
for media in content.values():
|
||||
media = _resolve_ref(doc, media)
|
||||
schema = media.get("schema")
|
||||
if schema:
|
||||
return _resolve_ref(doc, schema)
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
parts = [p for p in path.split("/") if p not in {"{project_id}", "{tenant_id}"}]
|
||||
normalized = "/" + "/".join(p for p in parts if p)
|
||||
return normalized.replace("//", "/")
|
||||
|
||||
|
||||
def import_service(openapi_name: str, pack_name: str) -> int:
|
||||
url = _pick_spec_url(openapi_name)
|
||||
print(f"Fetching {url}")
|
||||
doc = _load_yaml(_http_text(url))
|
||||
paths = doc.get("paths") or {}
|
||||
by_path: dict[str, dict[str, Any]] = {}
|
||||
for path, item in paths.items():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
norm = _normalize_path(str(path))
|
||||
for method, operation in item.items():
|
||||
if method.upper() not in {"POST", "PUT", "PATCH"}:
|
||||
continue
|
||||
if not isinstance(operation, dict):
|
||||
continue
|
||||
schema = _request_schema(doc, operation)
|
||||
if not schema:
|
||||
continue
|
||||
by_path[f"{method.upper()} {norm}"] = schema
|
||||
|
||||
out_path = OUT_DIR / f"{pack_name}.json"
|
||||
existing: dict[str, Any] = {"service": pack_name, "operations": {}, "by_path": {}}
|
||||
if out_path.is_file():
|
||||
existing = json.loads(out_path.read_text())
|
||||
operations = dict(existing.get("operations") or {})
|
||||
existing_by_path = dict(existing.get("by_path") or {})
|
||||
# OpenAPI schemas override generated stubs for matching paths.
|
||||
existing_by_path.update(by_path)
|
||||
|
||||
matched = 0
|
||||
pack_api = ROOT / "contracts" / "openstack" / "dalmatian" / pack_name / "api.json"
|
||||
if pack_api.is_file():
|
||||
pack = json.loads(pack_api.read_text())
|
||||
for op in pack.get("operations") or []:
|
||||
if op.get("method") not in {"POST", "PUT", "PATCH"}:
|
||||
continue
|
||||
key = f"{op['method']} {op['path']}"
|
||||
schema = existing_by_path.get(key)
|
||||
if schema is None:
|
||||
continue
|
||||
operations[op["operation_id"]] = schema
|
||||
matched += 1
|
||||
|
||||
payload = {
|
||||
"service": pack_name,
|
||||
"source": f"openstack-openapi:{openapi_name}",
|
||||
"operations": operations,
|
||||
"by_path": existing_by_path,
|
||||
}
|
||||
out_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
print(f" {pack_name}: openapi_paths={len(by_path)} matched_ops={matched} → {out_path}")
|
||||
return matched
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--service",
|
||||
action="append",
|
||||
choices=sorted(SERVICE_MAP),
|
||||
help="OpenAPI service dir (repeatable). Default: all Tier-1",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
selected = args.service or list(SERVICE_MAP)
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
total = 0
|
||||
for openapi_name in selected:
|
||||
total += import_service(openapi_name, SERVICE_MAP[openapi_name])
|
||||
print(f"Done. matched operation schemas: {total}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user