feat(widgets): add backend CRUD, registry, and default seeding
Introduce a closed, compile-time widget registry and backend CRUD for dashboard widget instances. - Add dashboard_widgets SQLite table in SettingsStore with CRUD helpers and default seeding (Jellyfin + Backups) on first install. - Add Pydantic models with credential-key and secret-value rejection. - Add widgets router: /api/widgets/sources, /types, /instances CRUD. - Call ensure_defaults() in app lifespan so fresh installs seed defaults. - Add backend tests covering registry, CRUD, validation, and seeding. - Include SDD artifacts: exploration, proposal, spec, design, tasks.
This commit is contained in:
@@ -23,6 +23,7 @@ from media_library_viewer_api.observability import (
|
||||
)
|
||||
from media_library_viewer_api.routers import backups as backups_router
|
||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
||||
from media_library_viewer_api.routers import widgets as widgets_router
|
||||
from media_library_viewer_api.routers.settings import router as settings_router
|
||||
|
||||
from .services.backup_poller import get_backup_poller
|
||||
@@ -45,6 +46,10 @@ async def lifespan(app: FastAPI):
|
||||
write_prometheus_targets(get_settings_store())
|
||||
except Exception:
|
||||
logger.exception("Failed to write Prometheus file-SD targets during startup")
|
||||
try:
|
||||
get_settings_store().ensure_defaults()
|
||||
except Exception:
|
||||
logger.exception("Failed to seed default settings during startup")
|
||||
mail_queue = get_mail_queue()
|
||||
backup_poller = get_backup_poller()
|
||||
mail_queue.start()
|
||||
@@ -137,6 +142,7 @@ app.include_router(users.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(backups_router.router)
|
||||
app.include_router(widgets_router.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Pydantic models for the dashboard widget system."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
FORBIDDEN_CONFIG_KEYS = {
|
||||
"password",
|
||||
"token",
|
||||
"secret",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"private_key",
|
||||
"passphrase",
|
||||
"credential",
|
||||
}
|
||||
|
||||
|
||||
def _looks_secret(value: Any) -> bool:
|
||||
"""Heuristic to detect values that look like secrets/tokens."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return False
|
||||
lowered = value.lower()
|
||||
if value.startswith("sk-") or value.startswith("eyJ"):
|
||||
return True
|
||||
if len(value) > 64 and lowered.isalnum():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively reject credential keys and secret-looking values."""
|
||||
for key, value in config.items():
|
||||
if key.lower() in FORBIDDEN_CONFIG_KEYS:
|
||||
raise ValueError(f"Credential key '{key}' is not allowed in widget config")
|
||||
if _looks_secret(value):
|
||||
raise ValueError(f"Value for '{key}' looks like a secret")
|
||||
if isinstance(value, dict):
|
||||
_validate_config_keys(value)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
_validate_config_keys(item)
|
||||
return config
|
||||
|
||||
|
||||
class _WidgetInstanceBase(BaseModel):
|
||||
"""Shared fields between input and output widget models."""
|
||||
|
||||
addon_id: str
|
||||
widget_type: str
|
||||
title: str = Field(..., min_length=1)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
|
||||
@field_validator("config")
|
||||
@classmethod
|
||||
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||
return _validate_config_keys(value or {})
|
||||
|
||||
|
||||
class WidgetInstanceInput(_WidgetInstanceBase):
|
||||
"""Payload for creating or updating a widget instance."""
|
||||
|
||||
id: str | None = None
|
||||
|
||||
|
||||
class WidgetInstance(_WidgetInstanceBase):
|
||||
"""Persisted widget instance returned by the API."""
|
||||
|
||||
id: str
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class WidgetTypeInfo(BaseModel):
|
||||
"""Metadata about a built-in widget type."""
|
||||
|
||||
addon_id: str
|
||||
widget_type: str
|
||||
name: str
|
||||
description: str
|
||||
source_type: str
|
||||
config_schema: dict[str, Any]
|
||||
|
||||
|
||||
class WidgetDataResponse(BaseModel):
|
||||
"""Response from the per-widget data endpoint."""
|
||||
|
||||
widget_id: str
|
||||
widget_type: str
|
||||
data: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
fetched_at: int
|
||||
@@ -0,0 +1,113 @@
|
||||
"""REST API for dashboard widget instances and registry metadata."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.models.widgets import WidgetInstance, WidgetInstanceInput, WidgetTypeInfo
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.registry import (
|
||||
list_source_types,
|
||||
list_widget_types,
|
||||
validate_config,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||
|
||||
|
||||
def _registry_for_type(widget_type: str) -> dict[str, Any]:
|
||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
||||
|
||||
info = WIDGET_REGISTRY.get(widget_type)
|
||||
if not info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unknown widget type: {widget_type}",
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def _validate_widget_input(body: WidgetInstanceInput) -> None:
|
||||
"""Validate widget_type/addon_id match and config schema."""
|
||||
info = _registry_for_type(body.widget_type)
|
||||
expected_addon = info["addon_id"]
|
||||
if body.addon_id != expected_addon:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=(
|
||||
f"Widget type '{body.widget_type}' belongs to addon "
|
||||
f"'{expected_addon}', not '{body.addon_id}'"
|
||||
),
|
||||
)
|
||||
try:
|
||||
validate_config(body.widget_type, body.config)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
def list_sources() -> list[str]:
|
||||
"""Return all registered widget source types."""
|
||||
return list_source_types()
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
def list_types() -> list[WidgetTypeInfo]:
|
||||
"""Return metadata for all registered widget types."""
|
||||
return [info.model_dump() for info in list_widget_types()]
|
||||
|
||||
|
||||
@router.get("/instances")
|
||||
def list_instances(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return all persisted widget instances."""
|
||||
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
|
||||
|
||||
|
||||
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||
def create_instance(
|
||||
body: WidgetInstanceInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new widget instance."""
|
||||
_validate_widget_input(body)
|
||||
widget = store.upsert_widget(body.model_dump())
|
||||
return WidgetInstance(**widget).model_dump()
|
||||
|
||||
|
||||
@router.put("/instances/{widget_id}")
|
||||
def update_instance(
|
||||
widget_id: str,
|
||||
body: WidgetInstanceInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing widget instance."""
|
||||
existing = store.get_widget(widget_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||
if body.id is not None and body.id != widget_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="ID in path does not match ID in body",
|
||||
)
|
||||
_validate_widget_input(body)
|
||||
widget = store.upsert_widget(body.model_dump(), widget_id)
|
||||
return WidgetInstance(**widget).model_dump()
|
||||
|
||||
|
||||
@router.delete("/instances/{widget_id}")
|
||||
def delete_instance(
|
||||
widget_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Delete a widget instance."""
|
||||
existing = store.get_widget(widget_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||
store.delete_widget(widget_id)
|
||||
return {"status": "deleted"}
|
||||
@@ -18,6 +18,7 @@ from typing import Any
|
||||
import paramiko
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.models.widgets import _validate_config_keys
|
||||
|
||||
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||
LOCAL_MACHINE_ID = "local"
|
||||
@@ -163,6 +164,24 @@ class SettingsStore:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_dashboard_shortcuts_type ON dashboard_shortcuts(shortcut_type)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dashboard_widgets (
|
||||
id TEXT PRIMARY KEY,
|
||||
addon_id TEXT NOT NULL,
|
||||
widget_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)"
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -353,12 +372,8 @@ class SettingsStore:
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
def ensure_defaults(self) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||
if row and int(row[0]) > 0:
|
||||
return
|
||||
def _seed_local_machine(self) -> None:
|
||||
"""Seed the default local machine if none exists."""
|
||||
machine = _default_local_machine()
|
||||
now = int(time.time())
|
||||
config = {
|
||||
@@ -401,6 +416,49 @@ class SettingsStore:
|
||||
),
|
||||
)
|
||||
|
||||
def _seed_dashboard_widgets(self) -> None:
|
||||
"""Seed default dashboard widgets only when the table is empty."""
|
||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
||||
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone()
|
||||
if row and int(row[0]) > 0:
|
||||
return
|
||||
defaults = [
|
||||
{
|
||||
"id": "jellyfin-activity-default",
|
||||
"addon_id": "core",
|
||||
"widget_type": "jellyfin",
|
||||
"title": "Jellyfin activity",
|
||||
"config": {"machine_id": ""},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
},
|
||||
{
|
||||
"id": "backups-summary-default",
|
||||
"addon_id": "backups",
|
||||
"widget_type": "backups",
|
||||
"title": "Backups",
|
||||
"config": {},
|
||||
"enabled": True,
|
||||
"sort_order": 1,
|
||||
},
|
||||
]
|
||||
for widget in defaults:
|
||||
info = WIDGET_REGISTRY.get(widget["widget_type"])
|
||||
if not info or info["addon_id"] != widget["addon_id"]:
|
||||
continue
|
||||
self.upsert_widget(widget)
|
||||
|
||||
def ensure_defaults(self) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||
if not row or int(row[0]) == 0:
|
||||
self._seed_local_machine()
|
||||
self._seed_dashboard_widgets()
|
||||
|
||||
def list_machines(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
@@ -1306,6 +1364,119 @@ class SettingsStore:
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dashboard widgets
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"addon_id": row["addon_id"],
|
||||
"widget_type": row["widget_type"],
|
||||
"title": row["title"],
|
||||
"config": json.loads(row["config_json"] or "{}"),
|
||||
"enabled": bool(row["enabled"]),
|
||||
"sort_order": int(row["sort_order"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def _normalize_widget_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
widget_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current = self.get_widget(widget_id) if widget_id else None
|
||||
widget_id = (
|
||||
str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip()
|
||||
or uuid.uuid4().hex[:12]
|
||||
)
|
||||
addon_id = str(payload.get("addon_id") or (current or {}).get("addon_id", "")).strip()
|
||||
widget_type = str(
|
||||
payload.get("widget_type") or (current or {}).get("widget_type", "")
|
||||
).strip()
|
||||
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
|
||||
config = payload.get("config", (current or {}).get("config", {}))
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
# Defense-in-depth: reject credential keys at the store layer too.
|
||||
_validate_config_keys(config)
|
||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||
sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0)
|
||||
return {
|
||||
"id": widget_id,
|
||||
"addon_id": addon_id,
|
||||
"widget_type": widget_type,
|
||||
"title": title,
|
||||
"config": config,
|
||||
"enabled": enabled,
|
||||
"sort_order": sort_order,
|
||||
}
|
||||
|
||||
def list_widgets(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC"
|
||||
).fetchall()
|
||||
return [self._row_to_widget(row) for row in rows]
|
||||
|
||||
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
||||
if not widget_id:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)
|
||||
).fetchone()
|
||||
return self._row_to_widget(row) if row else None
|
||||
|
||||
def upsert_widget(self, payload: dict[str, Any], widget_id: str | None = None) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
widget = self._normalize_widget_payload(payload, widget_id)
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT created_at FROM dashboard_widgets WHERE id = ?",
|
||||
(widget["id"],),
|
||||
).fetchone()
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO dashboard_widgets (
|
||||
id, addon_id, widget_type, title, config_json, enabled,
|
||||
sort_order, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
addon_id = excluded.addon_id,
|
||||
widget_type = excluded.widget_type,
|
||||
title = excluded.title,
|
||||
config_json = excluded.config_json,
|
||||
enabled = excluded.enabled,
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
widget["id"],
|
||||
widget["addon_id"],
|
||||
widget["widget_type"],
|
||||
widget["title"],
|
||||
json.dumps(widget["config"]),
|
||||
1 if widget["enabled"] else 0,
|
||||
widget["sort_order"],
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_widget(widget["id"]) or widget
|
||||
|
||||
def delete_widget(self, widget_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
||||
|
||||
|
||||
_store: SettingsStore | None = None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Widget subsystem package."""
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Closed, compile-time widget registry.
|
||||
|
||||
New widget types and source adapters require a code change in Phase 1.
|
||||
There is no runtime plugin loading.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.models.widgets import WidgetTypeInfo
|
||||
|
||||
WIDGET_REGISTRY: dict[str, dict[str, Any]] = {
|
||||
"jellyfin": {
|
||||
"addon_id": "core",
|
||||
"name": "Jellyfin activity",
|
||||
"description": "Live sessions and idle users from a Jellyfin server.",
|
||||
"source_type": "jellyfin",
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"machine_id": {
|
||||
"type": "string",
|
||||
"description": "Jellyfin machine id (empty = default)",
|
||||
},
|
||||
},
|
||||
"required": ["machine_id"],
|
||||
},
|
||||
},
|
||||
"backups": {
|
||||
"addon_id": "backups",
|
||||
"name": "Backups",
|
||||
"description": "Backup job summary and active alerts.",
|
||||
"source_type": "backups",
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
"grafana-link": {
|
||||
"addon_id": "grafana",
|
||||
"name": "Grafana link",
|
||||
"description": "Deep-link to a Grafana dashboard or panel.",
|
||||
"source_type": "grafana",
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dashboard_uid": {
|
||||
"type": "string",
|
||||
"description": "Grafana dashboard UID",
|
||||
},
|
||||
"panel_id": {
|
||||
"type": "integer",
|
||||
"description": "Optional panel id",
|
||||
},
|
||||
},
|
||||
"required": ["dashboard_uid"],
|
||||
},
|
||||
},
|
||||
"prometheus-metric": {
|
||||
"addon_id": "prometheus",
|
||||
"name": "Prometheus metric",
|
||||
"description": "Instant query result rendered as a metric.",
|
||||
"source_type": "prometheus",
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"promql": {
|
||||
"type": "string",
|
||||
"description": "PromQL instant query",
|
||||
},
|
||||
},
|
||||
"required": ["promql"],
|
||||
},
|
||||
},
|
||||
"ssh-task": {
|
||||
"addon_id": "ssh-tasks",
|
||||
"name": "SSH task output",
|
||||
"description": "Output of a saved task run on a machine.",
|
||||
"source_type": "ssh_task",
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "Saved task id",
|
||||
},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
},
|
||||
"static": {
|
||||
"addon_id": "core",
|
||||
"name": "Static text",
|
||||
"description": "Plain text or markdown note.",
|
||||
"source_type": "static",
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text or markdown content",
|
||||
},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_source_types() -> list[str]:
|
||||
"""Return all registered source type names."""
|
||||
return sorted({info["source_type"] for info in WIDGET_REGISTRY.values()})
|
||||
|
||||
|
||||
def list_widget_types() -> list[WidgetTypeInfo]:
|
||||
"""Return metadata for all registered widget types."""
|
||||
return [
|
||||
WidgetTypeInfo(
|
||||
addon_id=info["addon_id"],
|
||||
widget_type=widget_type,
|
||||
name=info["name"],
|
||||
description=info["description"],
|
||||
source_type=info["source_type"],
|
||||
config_schema=info["config_schema"],
|
||||
)
|
||||
for widget_type, info in WIDGET_REGISTRY.items()
|
||||
]
|
||||
|
||||
|
||||
def get_widget_info(widget_type: str) -> WidgetTypeInfo | None:
|
||||
"""Return metadata for a single widget type, or None if unknown."""
|
||||
info = WIDGET_REGISTRY.get(widget_type)
|
||||
if not info:
|
||||
return None
|
||||
return WidgetTypeInfo(
|
||||
addon_id=info["addon_id"],
|
||||
widget_type=widget_type,
|
||||
name=info["name"],
|
||||
description=info["description"],
|
||||
source_type=info["source_type"],
|
||||
config_schema=info["config_schema"],
|
||||
)
|
||||
|
||||
|
||||
def _validate_type(value: Any, expected: str) -> bool:
|
||||
if expected == "string":
|
||||
return isinstance(value, str)
|
||||
if expected == "integer":
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
if expected == "boolean":
|
||||
return isinstance(value, bool)
|
||||
if expected == "number":
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
if expected == "object":
|
||||
return isinstance(value, dict)
|
||||
if expected == "array":
|
||||
return isinstance(value, list)
|
||||
return True
|
||||
|
||||
|
||||
def validate_config(widget_type: str, config: dict[str, Any]) -> None:
|
||||
"""Validate a widget config against its registered JSON schema.
|
||||
|
||||
Raises ValueError with a descriptive message if validation fails.
|
||||
Phase 1 supports only required-field and primitive-type checks.
|
||||
"""
|
||||
info = WIDGET_REGISTRY.get(widget_type)
|
||||
if not info:
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
schema = info["config_schema"]
|
||||
required = schema.get("required", [])
|
||||
properties = schema.get("properties", {})
|
||||
|
||||
for key in required:
|
||||
if key not in config:
|
||||
raise ValueError(f"Missing required config field: {key}")
|
||||
|
||||
for key, value in config.items():
|
||||
prop = properties.get(key)
|
||||
if not prop:
|
||||
# Unknown keys are allowed in Phase 1 unless they look like secrets
|
||||
# (handled by the model validator). Skip type checks for unknowns.
|
||||
continue
|
||||
expected_type = prop.get("type")
|
||||
if expected_type and not _validate_type(value, expected_type):
|
||||
raise ValueError(f"Config field '{key}' must be of type {expected_type}")
|
||||
Reference in New Issue
Block a user