245 lines
9.5 KiB
Python

"""Load and validate the HUX (Hermes user-experience) contract schemas.
The schemas under ``services/hermes/contracts/hux`` are plain JSON Schema
2020-12 so browser and Go consumers can validate with their usual libraries.
CI has no ``jsonschema`` package, so this module carries a small validator for
the keyword subset the contracts actually use. Unsupported keywords fail
loudly rather than silently passing.
"""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any
CONTRACT_DIR = Path(
os.environ.get("HUX_CONTRACT_DIR")
or Path(__file__).resolve().parents[3] / "services" / "hermes" / "contracts" / "hux"
)
SCHEMA_FILES = (
"common.schema.json",
"identity.schema.json",
"event.schema.json",
"memory.schema.json",
"project.schema.json",
"artifact.schema.json",
"permission.schema.json",
"mode.schema.json",
"multimodal.schema.json",
"citation.schema.json",
"suggestion.schema.json",
"privacy.schema.json",
"release.schema.json",
"release-ledger.schema.json",
)
SUPPORTED_KEYWORDS = frozenset(
{
"$schema", "$id", "$defs", "$ref", "title", "description",
"type", "const", "enum", "required", "properties",
"additionalProperties", "items", "minItems", "maxItems",
"uniqueItems", "minLength", "maxLength", "pattern",
"minimum", "maximum", "oneOf",
}
)
_TYPE_CHECKS = {
"object": lambda v: isinstance(v, dict),
"array": lambda v: isinstance(v, list),
"string": lambda v: isinstance(v, str),
"boolean": lambda v: isinstance(v, bool),
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
"null": lambda v: v is None,
}
class ContractError(ValueError):
"""Raised when a schema uses something this validator does not support."""
def load_schema(name: str, directory: Path = CONTRACT_DIR) -> dict[str, Any]:
"""Read one schema file by name."""
return json.loads((directory / name).read_text(encoding="utf-8"))
def load_all(directory: Path = CONTRACT_DIR) -> dict[str, dict[str, Any]]:
"""Read every contract schema keyed by file name."""
return {name: load_schema(name, directory) for name in SCHEMA_FILES}
def load_flags(directory: Path = CONTRACT_DIR) -> dict[str, Any]:
"""Read the feature flag registry."""
return json.loads((directory / "flags.json").read_text(encoding="utf-8"))
def _walk(node: Any, path: str, problems: list[str]) -> None:
if isinstance(node, dict):
for key, value in node.items():
if path.endswith(("/properties", "/$defs")):
_walk(value, f"{path}/{key}", problems)
continue
if key not in SUPPORTED_KEYWORDS:
problems.append(f"{path}/{key}")
continue
_walk(value, f"{path}/{key}", problems)
elif isinstance(node, list):
for index, value in enumerate(node):
_walk(value, f"{path}/{index}", problems)
def unsupported_keywords(schema: dict[str, Any]) -> list[str]:
"""Return JSON-pointer style paths of keywords the validator ignores."""
problems: list[str] = []
_walk(schema, "#", problems)
return problems
def _resolve_ref(ref: str, current: str, schemas: dict[str, dict[str, Any]]) -> tuple[dict[str, Any], str]:
file_part, _, pointer = ref.partition("#")
file_name = file_part or current
if file_name not in schemas:
raise ContractError(f"unknown schema reference {ref!r}")
node: Any = schemas[file_name]
for token in [t for t in pointer.split("/") if t]:
if not isinstance(node, dict) or token not in node:
raise ContractError(f"unresolvable pointer {ref!r}")
node = node[token]
return node, file_name
def _check_type(schema: dict[str, Any], value: Any, path: str, errors: list[str]) -> bool:
expected = schema.get("type")
if expected is None:
return True
if expected not in _TYPE_CHECKS:
raise ContractError(f"unsupported type {expected!r} at {path}")
if not _TYPE_CHECKS[expected](value):
errors.append(f"{path}: expected {expected}")
return False
return True
def _check_scalars(schema: dict[str, Any], value: Any, path: str, errors: list[str]) -> None:
if "const" in schema and value != schema["const"]:
errors.append(f"{path}: expected constant {schema['const']!r}")
if "enum" in schema and value not in schema["enum"]:
errors.append(f"{path}: {value!r} not in enum")
if isinstance(value, str):
if "minLength" in schema and len(value) < schema["minLength"]:
errors.append(f"{path}: shorter than {schema['minLength']}")
if "maxLength" in schema and len(value) > schema["maxLength"]:
errors.append(f"{path}: longer than {schema['maxLength']}")
if "pattern" in schema and not re.search(schema["pattern"], value):
errors.append(f"{path}: does not match {schema['pattern']!r}")
if isinstance(value, (int, float)) and not isinstance(value, bool):
if "minimum" in schema and value < schema["minimum"]:
errors.append(f"{path}: below minimum {schema['minimum']}")
if "maximum" in schema and value > schema["maximum"]:
errors.append(f"{path}: above maximum {schema['maximum']}")
def _check_object(schema, value, path, errors, current, schemas) -> None:
properties = schema.get("properties", {})
for key in schema.get("required", []):
if key not in value:
errors.append(f"{path}: missing required {key!r}")
for key, item in value.items():
if key in properties:
_validate(properties[key], item, f"{path}/{key}", errors, current, schemas)
elif schema.get("additionalProperties") is False:
errors.append(f"{path}: unexpected property {key!r}")
def _check_array(schema, value, path, errors, current, schemas) -> None:
if "minItems" in schema and len(value) < schema["minItems"]:
errors.append(f"{path}: fewer than {schema['minItems']} items")
if "maxItems" in schema and len(value) > schema["maxItems"]:
errors.append(f"{path}: more than {schema['maxItems']} items")
if schema.get("uniqueItems"):
seen = [json.dumps(item, sort_keys=True) for item in value]
if len(set(seen)) != len(seen):
errors.append(f"{path}: items are not unique")
if "items" in schema:
for index, item in enumerate(value):
_validate(schema["items"], item, f"{path}/{index}", errors, current, schemas)
def _check_one_of(schema, value, path, errors, current, schemas) -> None:
attempts: list[list[str]] = []
for option in schema["oneOf"]:
sub: list[str] = []
_validate(option, value, path, sub, current, schemas)
attempts.append(sub)
matches = sum(not sub for sub in attempts)
if matches != 1:
errors.append(f"{path}: matched {matches} oneOf branches, expected exactly 1")
if matches == 0:
errors.extend(min(attempts, key=len))
def _validate(schema, value, path, errors, current, schemas) -> None:
if "$ref" in schema:
target, file_name = _resolve_ref(schema["$ref"], current, schemas)
_validate(target, value, path, errors, file_name, schemas)
return
if not _check_type(schema, value, path, errors):
return
_check_scalars(schema, value, path, errors)
if isinstance(value, dict):
_check_object(schema, value, path, errors, current, schemas)
if isinstance(value, list):
_check_array(schema, value, path, errors, current, schemas)
if "oneOf" in schema:
_check_one_of(schema, value, path, errors, current, schemas)
def validate(
schema_name: str,
value: Any,
schemas: dict[str, dict[str, Any]] | None = None,
pointer: str = "",
) -> list[str]:
"""Validate ``value`` against a schema file, or a ``#/$defs/...`` pointer inside it.
Returns a list of human-readable problems; an empty list means valid.
"""
schemas = schemas or load_all()
schema, file_name = _resolve_ref(f"{schema_name}#{pointer}", schema_name, schemas)
errors: list[str] = []
_validate(schema, value, "$", errors, file_name, schemas)
return errors
def record_schema_names(schemas: dict[str, dict[str, Any]] | None = None) -> dict[str, str]:
"""Map every ``schema`` constant (e.g. ``hux.event.v1``) to its file name."""
schemas = schemas or load_all()
found: dict[str, str] = {}
def visit(node: Any, file_name: str) -> None:
if isinstance(node, dict):
const = node.get("properties", {}).get("schema", {}).get("const")
if isinstance(const, str):
found[const] = file_name
for child in node.values():
visit(child, file_name)
elif isinstance(node, list):
for child in node:
visit(child, file_name)
for file_name, schema in schemas.items():
visit(schema, file_name)
return found
def validate_record(value: Any, schemas: dict[str, dict[str, Any]] | None = None) -> list[str]:
"""Validate a record by its own ``schema`` field."""
schemas = schemas or load_all()
if not isinstance(value, dict) or not isinstance(value.get("schema"), str):
return ["$: record has no string 'schema' field"]
file_name = record_schema_names(schemas).get(value["schema"])
if file_name is None:
return [f"$: unknown record schema {value['schema']!r}"]
return validate(file_name, value, schemas)