245 lines
9.5 KiB
Python
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)
|