417 lines
15 KiB
Python
417 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ValidationIssue:
|
|
path: str
|
|
message: str
|
|
severity: str = "error"
|
|
allowed_values: list[str] | None = None
|
|
allowed_values_hidden_count: int | None = None
|
|
|
|
def to_dict(self) -> dict:
|
|
result = {"path": self.path, "message": self.message, "severity": self.severity}
|
|
if self.allowed_values is not None:
|
|
result["allowedValues"] = self.allowed_values
|
|
if self.allowed_values_hidden_count is not None:
|
|
result["allowedValuesHiddenCount"] = self.allowed_values_hidden_count
|
|
return result
|
|
|
|
|
|
@dataclass
|
|
class ValidationResult:
|
|
ok: bool = True
|
|
issues: list[ValidationIssue] = field(default_factory=list)
|
|
warnings: list[ValidationIssue] = field(default_factory=list)
|
|
legacy_issues: list[ValidationIssue] = field(default_factory=list)
|
|
|
|
def add_issue(
|
|
self,
|
|
path: str,
|
|
message: str,
|
|
severity: str = "error",
|
|
allowed_values: list[str] | None = None,
|
|
allowed_values_hidden_count: int | None = None,
|
|
) -> None:
|
|
issue = ValidationIssue(
|
|
path=path,
|
|
message=message,
|
|
severity=severity,
|
|
allowed_values=allowed_values,
|
|
allowed_values_hidden_count=allowed_values_hidden_count,
|
|
)
|
|
if severity == "warning":
|
|
self.warnings.append(issue)
|
|
elif severity == "legacy":
|
|
self.legacy_issues.append(issue)
|
|
else:
|
|
self.issues.append(issue)
|
|
self.ok = False
|
|
|
|
def merge(self, other: ValidationResult) -> None:
|
|
self.issues.extend(other.issues)
|
|
self.warnings.extend(other.warnings)
|
|
self.legacy_issues.extend(other.legacy_issues)
|
|
if not other.ok:
|
|
self.ok = False
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"ok": self.ok,
|
|
"issues": [i.to_dict() for i in self.issues],
|
|
"warnings": [w.to_dict() for w in self.warnings],
|
|
"legacy_issues": [li.to_dict() for li in self.legacy_issues],
|
|
}
|
|
|
|
|
|
# ── Schema Validation ────────────────────────────────────
|
|
|
|
|
|
def _collect_allowed_values_from_schema_node(node: dict) -> tuple[list[str], bool]:
|
|
if "const" in node:
|
|
return [str(node["const"])], False
|
|
if "enum" in node and isinstance(node["enum"], list):
|
|
return [str(v) for v in node["enum"]], False
|
|
node_type = node.get("type")
|
|
if node_type == "boolean" or (isinstance(node_type, list) and "boolean" in node_type):
|
|
return ["true", "false"], False
|
|
|
|
union_branches = node.get("anyOf") or node.get("oneOf")
|
|
if not isinstance(union_branches, list):
|
|
return [], False
|
|
|
|
collected: list[str] = []
|
|
for branch in union_branches:
|
|
if not isinstance(branch, dict):
|
|
return [], True
|
|
branch_values, incomplete = _collect_allowed_values_from_schema_node(branch)
|
|
if incomplete or not branch_values:
|
|
return [], True
|
|
collected.extend(branch_values)
|
|
return collected, False
|
|
|
|
|
|
def _lookup_schema_node(schema: dict, path_parts: list[str]) -> dict | None:
|
|
current: Any = schema
|
|
for part in path_parts:
|
|
if not isinstance(current, dict):
|
|
return None
|
|
properties = current.get("properties")
|
|
if isinstance(properties, dict):
|
|
current = properties.get(part)
|
|
elif "additionalProperties" in current:
|
|
current = current["additionalProperties"]
|
|
else:
|
|
return None
|
|
return current if isinstance(current, dict) else None
|
|
|
|
|
|
def _check_type(entry_value: Any, expected_type: str) -> bool:
|
|
if expected_type == "string":
|
|
return isinstance(entry_value, str)
|
|
if expected_type == "number":
|
|
return isinstance(entry_value, (int, float)) and not isinstance(entry_value, bool)
|
|
if expected_type == "integer":
|
|
return isinstance(entry_value, int) and not isinstance(entry_value, bool)
|
|
if expected_type == "boolean":
|
|
return isinstance(entry_value, bool)
|
|
if expected_type == "array":
|
|
return isinstance(entry_value, list)
|
|
if expected_type == "object":
|
|
return isinstance(entry_value, dict)
|
|
if expected_type == "null":
|
|
return entry_value is None
|
|
return True
|
|
|
|
|
|
def _matches_type(entry_value: Any, prop_type: str | list[str] | None) -> bool:
|
|
if prop_type is None:
|
|
return True
|
|
if isinstance(prop_type, list):
|
|
return any(_check_type(entry_value, t) for t in prop_type)
|
|
return _check_type(entry_value, prop_type)
|
|
|
|
|
|
def _validate_numeric_bounds(
|
|
entry_value: Any,
|
|
prop_schema: dict,
|
|
field_path: str,
|
|
key: str,
|
|
result: ValidationResult,
|
|
) -> None:
|
|
if not isinstance(entry_value, (int, float)) or isinstance(entry_value, bool):
|
|
return
|
|
|
|
minimum = prop_schema.get("minimum")
|
|
maximum = prop_schema.get("maximum")
|
|
exclusive_minimum = prop_schema.get("exclusiveMinimum")
|
|
exclusive_maximum = prop_schema.get("exclusiveMaximum")
|
|
|
|
if minimum is not None and entry_value < minimum:
|
|
result.add_issue(field_path, f"'{key}' must be >= {minimum}")
|
|
if maximum is not None and entry_value > maximum:
|
|
result.add_issue(field_path, f"'{key}' must be <= {maximum}")
|
|
if exclusive_minimum is not None and entry_value <= exclusive_minimum:
|
|
result.add_issue(field_path, f"'{key}' must be > {exclusive_minimum}")
|
|
if exclusive_maximum is not None and entry_value >= exclusive_maximum:
|
|
result.add_issue(field_path, f"'{key}' must be < {exclusive_maximum}")
|
|
|
|
|
|
def _validate_string_constraints(
|
|
entry_value: Any,
|
|
prop_schema: dict,
|
|
field_path: str,
|
|
key: str,
|
|
result: ValidationResult,
|
|
) -> None:
|
|
if not isinstance(entry_value, str):
|
|
return
|
|
|
|
min_length = prop_schema.get("minLength")
|
|
max_length = prop_schema.get("maxLength")
|
|
pattern = prop_schema.get("pattern")
|
|
format_ = prop_schema.get("format")
|
|
|
|
if min_length is not None and len(entry_value) < min_length:
|
|
result.add_issue(field_path, f"'{key}' must be at least {min_length} characters")
|
|
if max_length is not None and len(entry_value) > max_length:
|
|
result.add_issue(field_path, f"'{key}' must be at most {max_length} characters")
|
|
if pattern is not None:
|
|
import re
|
|
|
|
try:
|
|
if not re.search(pattern, entry_value):
|
|
result.add_issue(field_path, f"'{key}' does not match pattern '{pattern}'")
|
|
except re.error:
|
|
logger.warning("Invalid pattern in schema for '%s': %s", key, pattern)
|
|
if format_ == "email" and "@" not in entry_value:
|
|
result.add_issue(field_path, f"'{key}' must be a valid email address")
|
|
if format_ == "uri" and not (entry_value.startswith("http://") or entry_value.startswith("https://")):
|
|
result.add_issue(field_path, f"'{key}' must be a valid URI")
|
|
|
|
|
|
def _validate_array_constraints(
|
|
entry_value: Any,
|
|
prop_schema: dict,
|
|
field_path: str,
|
|
key: str,
|
|
result: ValidationResult,
|
|
) -> None:
|
|
if not isinstance(entry_value, list):
|
|
return
|
|
|
|
min_items = prop_schema.get("minItems")
|
|
max_items = prop_schema.get("maxItems")
|
|
items_schema = prop_schema.get("items")
|
|
|
|
if min_items is not None and len(entry_value) < min_items:
|
|
result.add_issue(field_path, f"'{key}' must have at least {min_items} items")
|
|
if max_items is not None and len(entry_value) > max_items:
|
|
result.add_issue(field_path, f"'{key}' must have at most {max_items} items")
|
|
|
|
if isinstance(items_schema, dict):
|
|
for i, item in enumerate(entry_value):
|
|
item_path = f"{field_path}[{i}]"
|
|
item_type = items_schema.get("type")
|
|
if not _matches_type(item, item_type):
|
|
type_label = item_type if isinstance(item_type, str) else " | ".join(item_type) if isinstance(item_type, list) else "any"
|
|
result.add_issue(item_path, f"item expects a {type_label} value")
|
|
if isinstance(item, dict) and isinstance(items_schema.get("properties"), dict):
|
|
_validate_entry_against_schema(item, items_schema, item_path, result)
|
|
|
|
|
|
def _validate_entry_against_schema(
|
|
entry: dict,
|
|
schema: dict,
|
|
prefix: str,
|
|
result: ValidationResult,
|
|
) -> None:
|
|
if schema.get("type") != "object":
|
|
return
|
|
|
|
properties = schema.get("properties")
|
|
required_fields: list[str] = schema.get("required", [])
|
|
if not isinstance(properties, dict):
|
|
return
|
|
|
|
seen_keys: set[str] = set()
|
|
|
|
for key, entry_value in entry.items():
|
|
if key in ("channel_type", "account_id"):
|
|
continue
|
|
seen_keys.add(key)
|
|
|
|
prop_schema = properties.get(key) if isinstance(properties, dict) else None
|
|
if prop_schema is None:
|
|
continue
|
|
|
|
field_path = f"{prefix}.{key}"
|
|
prop_type = prop_schema.get("type")
|
|
|
|
if not _matches_type(entry_value, prop_type):
|
|
help_text = prop_schema.get("description", "")
|
|
if isinstance(prop_type, list):
|
|
type_label = " | ".join(prop_type)
|
|
elif isinstance(prop_type, str):
|
|
type_label = prop_type
|
|
else:
|
|
type_label = "any"
|
|
msg = f"'{key}' expects a {type_label} value. {help_text}".strip()
|
|
result.add_issue(field_path, msg)
|
|
continue
|
|
|
|
if isinstance(entry_value, dict) and isinstance(prop_schema.get("properties"), dict):
|
|
_validate_entry_against_schema(entry_value, prop_schema, field_path, result)
|
|
|
|
if isinstance(entry_value, str) and not entry_value.strip() and key in required_fields:
|
|
help_text = prop_schema.get("description", "")
|
|
msg = f"'{key}' is required but empty. {help_text}".strip()
|
|
result.add_issue(field_path, msg)
|
|
continue
|
|
|
|
if isinstance(entry_value, str) and entry_value.strip():
|
|
allowed, incomplete = _collect_allowed_values_from_schema_node(prop_schema)
|
|
if not incomplete and allowed and entry_value not in allowed:
|
|
result.add_issue(
|
|
field_path,
|
|
f"'{key}' has invalid value '{entry_value}'",
|
|
allowed_values=allowed,
|
|
)
|
|
|
|
_validate_numeric_bounds(entry_value, prop_schema, field_path, key, result)
|
|
_validate_string_constraints(entry_value, prop_schema, field_path, key, result)
|
|
_validate_array_constraints(entry_value, prop_schema, field_path, key, result)
|
|
|
|
for req_key in required_fields:
|
|
if req_key not in seen_keys:
|
|
prop_schema = properties.get(req_key, {})
|
|
help_text = prop_schema.get("description", "")
|
|
msg = f"missing required field '{req_key}'. {help_text}".strip()
|
|
result.add_issue(f"{prefix}.{req_key}", msg)
|
|
|
|
|
|
# ── Plugin Validation ────────────────────────────────────
|
|
|
|
|
|
def validate_channel_plugin(plugin: Any, result: ValidationResult) -> None:
|
|
from yuxi.channel.protocols import ConfigProtocol, ConfigSchemaProtocol
|
|
|
|
if not isinstance(plugin, ConfigProtocol):
|
|
return
|
|
|
|
plugin_id = getattr(plugin, "id", None)
|
|
if not plugin_id or not isinstance(plugin_id, str) or not plugin_id.strip():
|
|
result.add_issue("plugin", "channel plugin missing id")
|
|
return
|
|
|
|
prefix = f"plugin.{plugin_id}"
|
|
|
|
if not callable(getattr(plugin, "is_configured", None)):
|
|
result.add_issue(prefix, "missing is_configured method")
|
|
|
|
if not callable(getattr(plugin, "list_account_ids", None)):
|
|
result.add_issue(prefix, "missing list_account_ids method")
|
|
|
|
if not callable(getattr(plugin, "resolve_account", None)):
|
|
result.add_issue(prefix, "missing resolve_account method")
|
|
|
|
if isinstance(plugin, ConfigSchemaProtocol):
|
|
try:
|
|
schema = plugin.config_schema()
|
|
if not isinstance(schema, dict) or not schema:
|
|
result.add_issue(prefix, "config_schema() returned empty or invalid schema", "warning")
|
|
except Exception as e:
|
|
result.add_issue(prefix, f"config_schema() raised: {e}", "warning")
|
|
|
|
|
|
# ── Main Validation ──────────────────────────────────────
|
|
|
|
|
|
async def validate_all_channel_configs(
|
|
plugins: list,
|
|
config_entries: list[dict],
|
|
*,
|
|
validate_schema: bool = True,
|
|
validate_plugins: bool = False,
|
|
) -> ValidationResult:
|
|
result = ValidationResult()
|
|
|
|
from yuxi.channel.protocols import ConfigProtocol, ConfigSchemaProtocol
|
|
from yuxi.channel.secrets.models import is_secret_ref
|
|
|
|
configured_plugins = [p for p in plugins if isinstance(p, ConfigProtocol)]
|
|
if not configured_plugins:
|
|
result.add_issue("global", "No ConfigProtocol plugins registered")
|
|
return result
|
|
|
|
if validate_plugins:
|
|
for plugin in configured_plugins:
|
|
validate_channel_plugin(plugin, result)
|
|
|
|
plugin_ids = {p.id for p in configured_plugins}
|
|
|
|
for entry in config_entries:
|
|
channel_type = entry.get("channel_type", "unknown")
|
|
account_id = entry.get("account_id", "unknown")
|
|
prefix = f"channels.{channel_type}.{account_id}"
|
|
|
|
if channel_type not in plugin_ids:
|
|
result.add_issue(prefix, f"No plugin registered for channel_type '{channel_type}'")
|
|
continue
|
|
|
|
plugin = next((p for p in configured_plugins if p.id == channel_type), None)
|
|
if plugin is None:
|
|
result.add_issue(prefix, f"Plugin '{channel_type}' disappeared during validation")
|
|
continue
|
|
|
|
try:
|
|
configured = plugin.is_configured(entry)
|
|
except Exception as e:
|
|
result.add_issue(prefix, f"is_configured check failed: {e}")
|
|
continue
|
|
|
|
if not configured:
|
|
reason = ""
|
|
if callable(getattr(plugin, "unconfigured_reason", None)):
|
|
try:
|
|
reason = plugin.unconfigured_reason(entry, {}) or ""
|
|
except Exception:
|
|
pass
|
|
msg = "Account not fully configured"
|
|
if reason:
|
|
msg = f"{msg}: {reason}"
|
|
result.add_issue(prefix, msg, "warning")
|
|
continue
|
|
|
|
if validate_schema and isinstance(plugin, ConfigSchemaProtocol):
|
|
try:
|
|
schema = plugin.config_schema()
|
|
if isinstance(schema, dict) and schema:
|
|
_validate_entry_against_schema(entry, schema, prefix, result)
|
|
except Exception as e:
|
|
logger.warning("Schema validation skipped for %s: %s", channel_type, e)
|
|
|
|
has_secret_refs = any(
|
|
is_secret_ref(v) for v in entry.values() if isinstance(v, dict)
|
|
)
|
|
if has_secret_refs:
|
|
try:
|
|
from yuxi.channel.secrets import SecretResolver
|
|
|
|
resolver = SecretResolver()
|
|
resolved = await resolver.resolve(entry)
|
|
for issue in resolved.issues:
|
|
result.add_issue(f"{prefix}.secrets", str(issue))
|
|
for warning in resolved.warnings:
|
|
result.add_issue(f"{prefix}.secrets", str(warning), "warning")
|
|
except Exception as e:
|
|
result.add_issue(f"{prefix}.secrets", f"Secret resolution failed: {e}")
|
|
|
|
return result
|