Initial commit: VMware vSphere API simulator scaffold.
Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API contracts, docs, client examples, and the unit/integration/compatibility test suite for local client and tooling labs without a real vCenter.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""Normalize parsed API Viewer trees into stable contract models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any, cast
|
||||
|
||||
from app.contracts.model import (
|
||||
JsonValue,
|
||||
Manifest,
|
||||
Method,
|
||||
Parameter,
|
||||
PathContract,
|
||||
Permissions,
|
||||
Schema,
|
||||
Snapshot,
|
||||
canonical_json,
|
||||
sha256,
|
||||
)
|
||||
from app.contracts.source import ParsedSource
|
||||
|
||||
SCHEMA_FIELDS = {
|
||||
"type",
|
||||
"description",
|
||||
"properties",
|
||||
"items",
|
||||
"enum",
|
||||
"optional",
|
||||
"default",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"format",
|
||||
}
|
||||
METHOD_FIELDS = {
|
||||
"allowtoken",
|
||||
"description",
|
||||
"method",
|
||||
"name",
|
||||
"parameters",
|
||||
"permissions",
|
||||
"protected",
|
||||
"returns",
|
||||
}
|
||||
|
||||
|
||||
def _json(value: Any) -> JsonValue:
|
||||
return cast(JsonValue, value)
|
||||
|
||||
|
||||
def normalize_schema(raw: Mapping[str, Any] | None) -> Schema:
|
||||
source = raw or {}
|
||||
properties = source.get("properties") or {}
|
||||
normalized_properties = {
|
||||
str(name): normalize_schema(cast(Mapping[str, Any], schema))
|
||||
for name, schema in cast(Mapping[str, Any], properties).items()
|
||||
}
|
||||
items = source.get("items")
|
||||
extra = {key: _json(value) for key, value in source.items() if key not in SCHEMA_FIELDS}
|
||||
return Schema(
|
||||
type=source.get("type"),
|
||||
description=source.get("description"),
|
||||
properties=normalized_properties,
|
||||
items=normalize_schema(cast(Mapping[str, Any], items))
|
||||
if isinstance(items, Mapping)
|
||||
else None,
|
||||
enum=tuple(_json(value) for value in (source.get("enum") or ())),
|
||||
optional=bool(source["optional"]) if "optional" in source else None,
|
||||
default=_json(source.get("default")),
|
||||
minimum=source.get("minimum"),
|
||||
maximum=source.get("maximum"),
|
||||
min_length=source.get("minLength"),
|
||||
max_length=source.get("maxLength"),
|
||||
pattern=source.get("pattern"),
|
||||
format=_json(source.get("format")),
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def normalize_permissions(raw: Mapping[str, Any] | None) -> Permissions | None:
|
||||
if raw is None:
|
||||
return None
|
||||
known = {"user", "description"}
|
||||
expression_keys = {"and", "or", "check", "userParam"}
|
||||
return Permissions(
|
||||
user=raw.get("user"),
|
||||
description=raw.get("description"),
|
||||
expression={key: _json(raw[key]) for key in expression_keys if key in raw},
|
||||
extra={
|
||||
key: _json(value) for key, value in raw.items() if key not in known | expression_keys
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def normalize_method(verb: str, raw: Mapping[str, Any]) -> Method:
|
||||
parameters_raw = cast(Mapping[str, Any], raw.get("parameters") or {}).get("properties") or {}
|
||||
parameters = tuple(
|
||||
Parameter(name=str(name), definition=normalize_schema(cast(Mapping[str, Any], schema)))
|
||||
for name, schema in sorted(cast(Mapping[str, Any], parameters_raw).items())
|
||||
)
|
||||
values: dict[str, Any] = {
|
||||
"verb": verb.upper(),
|
||||
"name": str(raw.get("name", verb.lower())),
|
||||
"description": raw.get("description"),
|
||||
"parameters": parameters,
|
||||
"returns": normalize_schema(cast(Mapping[str, Any] | None, raw.get("returns"))),
|
||||
"permissions": normalize_permissions(
|
||||
cast(Mapping[str, Any] | None, raw.get("permissions"))
|
||||
),
|
||||
"protected": bool(raw.get("protected", False)),
|
||||
"allow_token": bool(raw["allowtoken"]) if "allowtoken" in raw else None,
|
||||
"extra": {key: _json(value) for key, value in raw.items() if key not in METHOD_FIELDS},
|
||||
}
|
||||
checksum = sha256(canonical_json(values))
|
||||
return Method(**values, checksum=checksum)
|
||||
|
||||
|
||||
def _walk(nodes: tuple[dict[str, Any], ...]) -> list[PathContract]:
|
||||
paths: list[PathContract] = []
|
||||
|
||||
def visit(node: Mapping[str, Any]) -> None:
|
||||
info = node.get("info")
|
||||
path = node.get("path")
|
||||
if isinstance(info, Mapping) and isinstance(path, str):
|
||||
methods = tuple(
|
||||
normalize_method(str(verb), cast(Mapping[str, Any], method))
|
||||
for verb, method in sorted(info.items())
|
||||
if isinstance(method, Mapping)
|
||||
)
|
||||
extra = {
|
||||
key: _json(value)
|
||||
for key, value in node.items()
|
||||
if key not in {"children", "info", "leaf", "path", "text"}
|
||||
}
|
||||
paths.append(PathContract(path=path, methods=methods, extra=extra))
|
||||
for child in node.get("children", ()):
|
||||
if isinstance(child, Mapping):
|
||||
visit(child)
|
||||
|
||||
for root in nodes:
|
||||
visit(root)
|
||||
return sorted(paths, key=lambda item: item.path)
|
||||
|
||||
|
||||
def normalize_snapshot(
|
||||
parsed: ParsedSource, *, raw: bytes, source_version: str, retrieved_at: datetime
|
||||
) -> tuple[Snapshot, Manifest]:
|
||||
paths = tuple(_walk(parsed.nodes))
|
||||
snapshot = Snapshot(
|
||||
source_version=source_version,
|
||||
retrieved_at=retrieved_at,
|
||||
raw_sha256=sha256(raw),
|
||||
paths=paths,
|
||||
path_count=len(paths),
|
||||
method_count=sum(len(path.methods) for path in paths),
|
||||
extra={"warning_count": len(parsed.warnings)},
|
||||
)
|
||||
manifest = Manifest(
|
||||
source_version=source_version,
|
||||
raw_sha256=snapshot.raw_sha256,
|
||||
snapshot_sha256=snapshot.checksum(),
|
||||
path_count=snapshot.path_count,
|
||||
method_count=snapshot.method_count,
|
||||
)
|
||||
return snapshot, manifest
|
||||
Reference in New Issue
Block a user