"""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