//! Schema validation for plugin UI pages //! //! Provides comprehensive validation of [`UiPage`] and [`UiElement`] trees //! before they are rendered. Call [`SchemaValidator::validate_page`] before //! registering a plugin page. use thiserror::Error; use crate::{DataSource, UiElement, UiPage, UiWidget}; /// Reserved routes that plugins cannot use const RESERVED_ROUTES: &[&str] = &[ "/", "/search", "/settings", "/admin", "/library", "/books", "/tags", "/collections", "/audit", "/import", "/duplicates", "/statistics", "/tasks", "/database", "/graph", ]; /// Errors produced by schema validation #[derive(Debug, Error)] pub enum ValidationError { /// A single validation failure #[error("Validation error: {0}")] Single(String), /// Multiple validation failures collected in one pass #[error("Validation failed with {} errors: {}", .0.len(), .0.join("; "))] Multiple(Vec), } /// Validates plugin UI schemas before they are loaded into the registry. /// /// # Example /// /// ```rust,ignore /// let page = plugin.manifest.ui.pages[0].clone(); /// SchemaValidator::validate_page(&page)?; /// ``` pub struct SchemaValidator; impl SchemaValidator { /// Validate a complete [`UiPage`] definition. /// /// Checks: /// - Page ID format (alphanumeric + dash/underscore, starts with a letter) /// - Route starts with `'/'` and is not reserved /// - `DataTable` elements have at least one column /// - Form elements have at least one field /// - Loop and Conditional elements have valid structure /// /// # Errors /// /// Returns [`ValidationError::Multiple`] containing all collected errors /// so callers can surface all problems at once. pub fn validate_page(page: &UiPage) -> Result<(), ValidationError> { let mut errors = Vec::new(); // ID format if !Self::is_valid_id(&page.id) { errors.push(format!( "Invalid page ID '{}': must start with a letter and contain only \ alphanumeric characters, dashes, or underscores", page.id )); } // Route format if !page.route.starts_with('/') { errors.push(format!("Route must start with '/': {}", page.route)); } // Reserved routes if Self::is_reserved_route(&page.route) { errors.push(format!("Route is reserved by the host: {}", page.route)); } // Validate data sources for (name, source) in &page.data_sources { Self::validate_data_source(name, source, &mut errors); } // Recursively validate element tree Self::validate_element(&page.root_element, &mut errors); if errors.is_empty() { Ok(()) } else { Err(ValidationError::Multiple(errors)) } } /// Validate a [`UiWidget`] definition. /// /// # Errors /// /// Returns [`ValidationError::Multiple`] with all collected errors. pub fn validate_widget(widget: &UiWidget) -> Result<(), ValidationError> { let mut errors = Vec::new(); if !Self::is_valid_id(&widget.id) { errors.push(format!( "Invalid widget ID '{}': must start with a letter and contain only \ alphanumeric characters, dashes, or underscores", widget.id )); } if widget.target.is_empty() { errors.push("Widget target must not be empty".to_string()); } Self::validate_element(&widget.content, &mut errors); if Self::element_references_data_source(&widget.content) { errors.push("widgets cannot reference data sources".to_string()); } if errors.is_empty() { Ok(()) } else { Err(ValidationError::Multiple(errors)) } } /// Recursively validate a [`UiElement`] subtree. pub fn validate_element(element: &UiElement, errors: &mut Vec) { match element { UiElement::Container { children, .. } | UiElement::Grid { children, .. } | UiElement::Flex { children, .. } => { for child in children { Self::validate_element(child, errors); } }, UiElement::Split { sidebar, main, .. } => { Self::validate_element(sidebar, errors); Self::validate_element(main, errors); }, UiElement::Tabs { tabs, .. } => { if tabs.is_empty() { errors.push("Tabs element must have at least one tab".to_string()); } for tab in tabs { Self::validate_element(&tab.content, errors); } }, UiElement::DataTable { data, columns, .. } => { if data.is_empty() { errors .push("DataTable 'data' source key must not be empty".to_string()); } if columns.is_empty() { errors.push("DataTable must have at least one column".to_string()); } }, UiElement::Form { fields, .. } => { if fields.is_empty() { errors.push("Form must have at least one field".to_string()); } for field in fields { if field.id.is_empty() { errors.push("Form field id must not be empty".to_string()); } } }, UiElement::Conditional { then, else_element, .. } => { Self::validate_element(then, errors); if let Some(else_branch) = else_element { Self::validate_element(else_branch, errors); } }, UiElement::Loop { template, .. } => { Self::validate_element(template, errors); }, UiElement::Card { content, footer, .. } => { for child in content.iter().chain(footer.iter()) { Self::validate_element(child, errors); } }, UiElement::List { data, item_template, .. } => { if data.is_empty() { errors.push("List 'data' source key must not be empty".to_string()); } Self::validate_element(item_template, errors); }, // Leaf elements with no children to recurse into UiElement::Heading { .. } | UiElement::Text { .. } | UiElement::Code { .. } | UiElement::MediaGrid { .. } | UiElement::DescriptionList { .. } | UiElement::Button { .. } | UiElement::Link { .. } | UiElement::Progress { .. } | UiElement::Badge { .. } | UiElement::Chart { .. } => {}, } } /// Returns true if any element in the tree references a named data source. /// /// Widgets have no data-fetching mechanism, so any data source reference /// in a widget content tree is invalid and must be rejected at load time. fn element_references_data_source(element: &UiElement) -> bool { match element { // Variants that reference a data source by name UiElement::DataTable { .. } | UiElement::MediaGrid { .. } | UiElement::DescriptionList { .. } | UiElement::Chart { .. } | UiElement::Loop { .. } | UiElement::List { .. } => true, // Container variants - recurse into children UiElement::Container { children, .. } | UiElement::Grid { children, .. } | UiElement::Flex { children, .. } => { children.iter().any(Self::element_references_data_source) }, UiElement::Split { sidebar, main, .. } => { Self::element_references_data_source(sidebar) || Self::element_references_data_source(main) }, UiElement::Tabs { tabs, .. } => { tabs .iter() .any(|tab| Self::element_references_data_source(&tab.content)) }, UiElement::Card { content, footer, .. } => { content.iter().any(Self::element_references_data_source) || footer.iter().any(Self::element_references_data_source) }, UiElement::Conditional { then, else_element, .. } => { Self::element_references_data_source(then) || else_element .as_ref() .is_some_and(|e| Self::element_references_data_source(e)) }, // Leaf elements with no data source references UiElement::Heading { .. } | UiElement::Text { .. } | UiElement::Code { .. } | UiElement::Button { .. } | UiElement::Form { .. } | UiElement::Link { .. } | UiElement::Progress { .. } | UiElement::Badge { .. } => false, } } fn validate_data_source( name: &str, source: &DataSource, errors: &mut Vec, ) { match source { DataSource::Endpoint { path, .. } => { if path.is_empty() { errors.push(format!( "Data source '{name}': endpoint path must not be empty" )); } if !path.starts_with('/') { errors.push(format!( "Data source '{name}': endpoint path must start with '/': {path}" )); } if !path.starts_with("/api/") { errors.push(format!( "DataSource '{name}': endpoint path must start with /api/ (got \ '{path}')" )); } }, DataSource::Transform { source_name, .. } => { if source_name.is_empty() { errors.push(format!( "Data source '{name}': transform source_name must not be empty" )); } }, DataSource::Static { .. } => {}, } } fn is_valid_id(id: &str) -> bool { if id.is_empty() || id.len() > 64 { return false; } let mut chars = id.chars(); chars.next().is_some_and(|c| c.is_ascii_alphabetic()) && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') } pub(crate) fn is_reserved_route(route: &str) -> bool { RESERVED_ROUTES.iter().any(|reserved| { route == *reserved || (route.starts_with(reserved) && route.as_bytes().get(reserved.len()) == Some(&b'/')) }) } } #[cfg(test)] mod tests { use std::collections::HashMap; use super::*; use crate::UiElement; fn make_page(id: &str, route: &str) -> UiPage { UiPage { id: id.to_string(), title: "Test Page".to_string(), route: route.to_string(), icon: None, root_element: UiElement::Container { children: vec![], gap: 0, padding: None, }, data_sources: HashMap::new(), actions: HashMap::new(), } } #[test] fn test_valid_page() { let page = make_page("my-plugin-page", "/plugins/test/page"); assert!(SchemaValidator::validate_page(&page).is_ok()); } #[test] fn test_invalid_id_starts_with_digit() { let page = make_page("1invalid", "/plugins/test/page"); assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_invalid_id_empty() { let page = make_page("", "/plugins/test/page"); assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_reserved_route() { let page = make_page("my-page", "/settings"); assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_route_missing_slash() { let page = make_page("my-page", "plugins/test"); assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_datatable_no_columns() { let mut page = make_page("my-page", "/plugins/test/page"); page.root_element = UiElement::DataTable { data: "items".to_string(), columns: vec![], sortable: false, filterable: false, page_size: 0, row_actions: vec![], }; let result = SchemaValidator::validate_page(&page); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("at least one column")); } #[test] fn test_form_no_fields() { let mut page = make_page("my-page", "/plugins/test/page"); page.root_element = UiElement::Form { fields: vec![], submit_action: crate::ActionRef::Name("submit".to_string()), submit_label: "Submit".to_string(), cancel_label: None, }; let result = SchemaValidator::validate_page(&page); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("at least one field")); } #[test] fn test_multiple_errors_collected() { let page = make_page("1bad-id", "/settings"); let result = SchemaValidator::validate_page(&page); assert!(result.is_err()); match result.unwrap_err() { ValidationError::Multiple(errs) => assert!(errs.len() >= 2), ValidationError::Single(_) => panic!("expected Multiple"), } } #[test] fn test_reserved_route_subpath_rejected() { // A sub-path of a reserved route must also be rejected for route in &[ "/settings/theme", "/admin/users", "/library/foo", "/search/advanced", "/tasks/pending", ] { let page = make_page("my-page", route); let result = SchemaValidator::validate_page(&page); assert!( result.is_err(), "expected error for sub-path of reserved route: {route}" ); } } #[test] fn test_plugin_route_not_reserved() { // Routes under /plugins/ are allowed (not in RESERVED_ROUTES) let page = make_page("my-page", "/plugins/my-plugin/page"); assert!(SchemaValidator::validate_page(&page).is_ok()); } #[test] fn test_id_max_length_accepted() { let id = "a".repeat(64); let page = make_page(&id, "/plugins/test"); assert!(SchemaValidator::validate_page(&page).is_ok()); } #[test] fn test_id_too_long_rejected() { let id = "a".repeat(65); let page = make_page(&id, "/plugins/test"); assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_id_with_dash_and_underscore() { let page = make_page("my-plugin_page", "/plugins/test"); assert!(SchemaValidator::validate_page(&page).is_ok()); } #[test] fn test_id_with_special_chars_rejected() { let page = make_page("my page!", "/plugins/test"); assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_datatable_empty_data_key() { let col: crate::ColumnDef = serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"})) .unwrap(); let mut page = make_page("my-page", "/plugins/test/page"); page.root_element = UiElement::DataTable { data: String::new(), columns: vec![col], sortable: false, filterable: false, page_size: 0, row_actions: vec![], }; assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_form_field_empty_id_rejected() { let field: crate::FormField = serde_json::from_value( serde_json::json!({"id": "", "label": "Name", "type": {"type": "text"}}), ) .unwrap(); let mut page = make_page("my-page", "/plugins/test/page"); page.root_element = UiElement::Form { fields: vec![field], submit_action: crate::ActionRef::Name("submit".to_string()), submit_label: "Submit".to_string(), cancel_label: None, }; let result = SchemaValidator::validate_page(&page); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("field id")); } #[test] fn test_valid_widget() { let widget = crate::UiWidget { id: "my-widget".to_string(), target: "library_header".to_string(), content: UiElement::Container { children: vec![], gap: 0, padding: None, }, }; assert!(SchemaValidator::validate_widget(&widget).is_ok()); } #[test] fn test_widget_invalid_id() { let widget = crate::UiWidget { id: "1bad".to_string(), target: "library_header".to_string(), content: UiElement::Container { children: vec![], gap: 0, padding: None, }, }; assert!(SchemaValidator::validate_widget(&widget).is_err()); } #[test] fn test_widget_empty_target() { let widget = crate::UiWidget { id: "my-widget".to_string(), target: String::new(), content: UiElement::Container { children: vec![], gap: 0, padding: None, }, }; assert!(SchemaValidator::validate_widget(&widget).is_err()); } #[test] fn test_data_source_empty_endpoint_path() { use crate::{DataSource, HttpMethod}; let mut page = make_page("my-page", "/plugins/test"); page .data_sources .insert("items".to_string(), DataSource::Endpoint { path: String::new(), method: HttpMethod::Get, params: Default::default(), poll_interval: 0, transform: None, }); assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_data_source_endpoint_path_no_leading_slash() { use crate::{DataSource, HttpMethod}; let mut page = make_page("my-page", "/plugins/test"); page .data_sources .insert("items".to_string(), DataSource::Endpoint { path: "api/v1/items".to_string(), method: HttpMethod::Get, params: Default::default(), poll_interval: 0, transform: None, }); assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_data_source_endpoint_valid() { use crate::{DataSource, HttpMethod}; let mut page = make_page("my-page", "/plugins/test"); page .data_sources .insert("items".to_string(), DataSource::Endpoint { path: "/api/v1/items".to_string(), method: HttpMethod::Get, params: Default::default(), poll_interval: 0, transform: None, }); assert!(SchemaValidator::validate_page(&page).is_ok()); } #[test] fn test_data_source_transform_empty_source_name() { use crate::DataSource; let mut page = make_page("my-page", "/plugins/test"); page .data_sources .insert("derived".to_string(), DataSource::Transform { source_name: String::new(), expression: crate::Expression::Literal(serde_json::Value::Null), }); assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_tabs_empty_rejected() { let mut page = make_page("my-page", "/plugins/test"); page.root_element = UiElement::Tabs { tabs: vec![], default_tab: 0, }; assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_list_empty_data_key() { let mut page = make_page("my-page", "/plugins/test"); page.root_element = UiElement::List { data: String::new(), item_template: Box::new(UiElement::Container { children: vec![], gap: 0, padding: None, }), dividers: false, }; assert!(SchemaValidator::validate_page(&page).is_err()); } #[test] fn test_widget_badge_content_passes_validation() { let widget = crate::UiWidget { id: "status-badge".to_string(), target: "library_header".to_string(), content: UiElement::Badge { text: "active".to_string(), variant: Default::default(), }, }; assert!( SchemaValidator::validate_widget(&widget).is_ok(), "a widget with Badge content should pass validation" ); } #[test] fn test_widget_datatable_fails_validation() { let col: crate::ColumnDef = serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"})) .unwrap(); let widget = crate::UiWidget { id: "my-widget".to_string(), target: "library_header".to_string(), content: UiElement::DataTable { data: "items".to_string(), columns: vec![col], sortable: false, filterable: false, page_size: 0, row_actions: vec![], }, }; let result = SchemaValidator::validate_widget(&widget); assert!( result.is_err(), "DataTable in widget should fail validation" ); let err = result.unwrap_err().to_string(); assert!( err.contains("cannot reference data sources"), "error message should mention data sources: {err}" ); } #[test] fn test_widget_container_with_loop_fails_validation() { // Container whose child is a Loop - recursive check must catch it let widget = crate::UiWidget { id: "loop-widget".to_string(), target: "library_header".to_string(), content: UiElement::Container { children: vec![UiElement::Loop { data: "items".to_string(), template: Box::new(UiElement::Text { content: Default::default(), variant: Default::default(), allow_html: false, }), empty: None, }], gap: 0, padding: None, }, }; let result = SchemaValidator::validate_widget(&widget); assert!( result.is_err(), "Container wrapping a Loop should fail widget validation" ); let err = result.unwrap_err().to_string(); assert!( err.contains("cannot reference data sources"), "error message should mention data sources: {err}" ); } }