Add OpenStack request-body schemas and nested console PARAM sync.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
"""Build UI body_fields / body_example from JSON Schema request bodies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def schema_example(schema: dict[str, Any] | None, *, name: str | None = None) -> Any:
|
||||
"""Build a representative JSON value from a JSON Schema fragment."""
|
||||
|
||||
if not schema:
|
||||
return None
|
||||
if "example" in schema:
|
||||
return schema["example"]
|
||||
if "default" in schema:
|
||||
return schema["default"]
|
||||
enum_values = schema.get("enum") or []
|
||||
if enum_values:
|
||||
return enum_values[0]
|
||||
if "const" in schema:
|
||||
return schema["const"]
|
||||
|
||||
schema_type = schema.get("type")
|
||||
if isinstance(schema_type, list):
|
||||
schema_type = next((t for t in schema_type if t != "null"), schema_type[0])
|
||||
|
||||
if schema_type == "object" or ("properties" in schema and schema_type is None):
|
||||
props = schema.get("properties") or {}
|
||||
required = set(schema.get("required") or [])
|
||||
out: dict[str, Any] = {}
|
||||
for key, child in props.items():
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
# Include required always; also optional fields that declare example/default.
|
||||
include = key in required or "example" in child or "default" in child
|
||||
if not include:
|
||||
# Still include optional leaves for api-ref-style full previews.
|
||||
include = True
|
||||
if include:
|
||||
out[key] = schema_example(child, name=key)
|
||||
return out
|
||||
|
||||
if schema_type == "array":
|
||||
items = schema.get("items")
|
||||
if isinstance(items, dict):
|
||||
return [schema_example(items, name=name)]
|
||||
return []
|
||||
|
||||
if schema_type == "boolean":
|
||||
return False
|
||||
if schema_type == "integer":
|
||||
minimum = schema.get("minimum")
|
||||
return int(minimum) if minimum is not None else 1
|
||||
if schema_type == "number":
|
||||
minimum = schema.get("minimum")
|
||||
return float(minimum) if minimum is not None else 1.0
|
||||
if schema_type == "null":
|
||||
return None
|
||||
|
||||
# string / unknown
|
||||
fmt = schema.get("format")
|
||||
if fmt == "uri" or fmt == "url":
|
||||
return "http://example.com"
|
||||
if fmt == "email":
|
||||
return "user@example.com"
|
||||
if fmt == "uuid" or (name and (name.endswith("_id") or name == "id")):
|
||||
return "00000000-0000-0000-0000-000000000001"
|
||||
if name in {"name", "stack_name", "display_name"}:
|
||||
return "example"
|
||||
if name in {"cidr", "remote_ip_prefix"}:
|
||||
return "10.0.0.0/24"
|
||||
if name in {"password"}:
|
||||
return "secret"
|
||||
return "example"
|
||||
|
||||
|
||||
def flatten_schema_fields(
|
||||
schema: dict[str, Any] | None,
|
||||
*,
|
||||
prefix: str = "",
|
||||
max_depth: int = 6,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Flatten a JSON Schema into dotted PARAM field descriptors for the console."""
|
||||
|
||||
if not schema or max_depth < 0:
|
||||
return []
|
||||
schema_type = schema.get("type")
|
||||
if isinstance(schema_type, list):
|
||||
schema_type = next((t for t in schema_type if t != "null"), schema_type[0])
|
||||
|
||||
props = schema.get("properties")
|
||||
if (schema_type == "object" or props) and isinstance(props, dict):
|
||||
required = set(schema.get("required") or [])
|
||||
fields: list[dict[str, Any]] = []
|
||||
for key, child in props.items():
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
path = f"{prefix}.{key}" if prefix else key
|
||||
child_type = child.get("type")
|
||||
if isinstance(child_type, list):
|
||||
child_type = next((t for t in child_type if t != "null"), child_type[0])
|
||||
nested_props = child.get("properties")
|
||||
if (child_type == "object" or nested_props) and isinstance(nested_props, dict):
|
||||
fields.extend(
|
||||
flatten_schema_fields(child, prefix=path, max_depth=max_depth - 1)
|
||||
)
|
||||
elif child_type == "array":
|
||||
items = child.get("items")
|
||||
if isinstance(items, dict) and (
|
||||
items.get("type") == "object" or isinstance(items.get("properties"), dict)
|
||||
):
|
||||
# Expand one sample element so nested array object fields appear.
|
||||
fields.extend(
|
||||
flatten_schema_fields(
|
||||
items, prefix=f"{path}.0", max_depth=max_depth - 1
|
||||
)
|
||||
)
|
||||
else:
|
||||
fields.append(
|
||||
{
|
||||
"name": path,
|
||||
"type": "array",
|
||||
"description": child.get("description") or f"Array {path}",
|
||||
"optional": key not in required,
|
||||
"enum": list(child.get("enum") or []),
|
||||
"example": schema_example(child, name=key),
|
||||
}
|
||||
)
|
||||
else:
|
||||
fields.append(
|
||||
{
|
||||
"name": path,
|
||||
"type": str(child_type or "string"),
|
||||
"description": child.get("description"),
|
||||
"optional": key not in required,
|
||||
"enum": list(child.get("enum") or []),
|
||||
"example": schema_example(child, name=key),
|
||||
}
|
||||
)
|
||||
return fields
|
||||
|
||||
if prefix:
|
||||
return [
|
||||
{
|
||||
"name": prefix,
|
||||
"type": str(schema_type or "string"),
|
||||
"description": schema.get("description"),
|
||||
"optional": False,
|
||||
"enum": list(schema.get("enum") or []),
|
||||
"example": schema_example(schema, name=prefix),
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def body_fields_from_example(body_example: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""PARAM inputs derived from body_example, including nested scalar paths.
|
||||
|
||||
Mirrors oVirt console behaviour: walk the Engine/OpenStack-shaped example and
|
||||
emit one PARAM row per leaf (dotted paths, numeric segments for arrays).
|
||||
"""
|
||||
|
||||
if not isinstance(body_example, dict) or not body_example:
|
||||
return []
|
||||
|
||||
fields: list[dict[str, Any]] = []
|
||||
|
||||
def _leaf_type(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return "integer"
|
||||
if isinstance(value, float):
|
||||
return "number"
|
||||
if value is None:
|
||||
return "null"
|
||||
return "string"
|
||||
|
||||
def _walk(prefix: str, value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
path = f"{prefix}.{key}" if prefix else str(key)
|
||||
_walk(path, child)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
if not value:
|
||||
fields.append(
|
||||
{
|
||||
"name": prefix,
|
||||
"type": "array",
|
||||
"description": prefix,
|
||||
"optional": True,
|
||||
"enum": [],
|
||||
"example": [],
|
||||
}
|
||||
)
|
||||
return
|
||||
for index, child in enumerate(value):
|
||||
path = f"{prefix}.{index}" if prefix else str(index)
|
||||
_walk(path, child)
|
||||
return
|
||||
fields.append(
|
||||
{
|
||||
"name": prefix,
|
||||
"type": _leaf_type(value),
|
||||
"description": prefix,
|
||||
"optional": True,
|
||||
"enum": [],
|
||||
"example": value,
|
||||
}
|
||||
)
|
||||
|
||||
_walk("", body_example)
|
||||
return fields
|
||||
|
||||
|
||||
def unflatten_body(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Turn dotted keys into a nested dict (supports numeric array segments)."""
|
||||
|
||||
root: dict[str, Any] = {}
|
||||
|
||||
def _set(path: str, value: Any) -> None:
|
||||
parts = path.split(".")
|
||||
cur: Any = root
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
nxt = parts[i + 1]
|
||||
want_array = nxt.isdigit()
|
||||
if part.isdigit():
|
||||
idx = int(part)
|
||||
if not isinstance(cur, list):
|
||||
return
|
||||
while len(cur) <= idx:
|
||||
cur.append([] if want_array else {})
|
||||
if cur[idx] is None or (want_array and not isinstance(cur[idx], list)) or (
|
||||
not want_array and not isinstance(cur[idx], dict)
|
||||
):
|
||||
cur[idx] = [] if want_array else {}
|
||||
cur = cur[idx]
|
||||
continue
|
||||
if want_array:
|
||||
if not isinstance(cur.get(part), list):
|
||||
cur[part] = []
|
||||
elif not isinstance(cur.get(part), dict):
|
||||
cur[part] = {}
|
||||
cur = cur[part]
|
||||
leaf = parts[-1]
|
||||
if leaf.isdigit():
|
||||
idx = int(leaf)
|
||||
if not isinstance(cur, list):
|
||||
return
|
||||
while len(cur) <= idx:
|
||||
cur.append(None)
|
||||
cur[idx] = value
|
||||
return
|
||||
if isinstance(cur, dict):
|
||||
cur[leaf] = value
|
||||
|
||||
for dotted, value in sorted(values.items(), key=lambda item: item[0].count(".")):
|
||||
if value is None or value == "":
|
||||
continue
|
||||
_set(dotted, value)
|
||||
return root
|
||||
Reference in New Issue
Block a user