diff --git a/crates/pinakes-plugin-api/src/lib.rs b/crates/pinakes-plugin-api/src/lib.rs index 34e806f..5440669 100644 --- a/crates/pinakes-plugin-api/src/lib.rs +++ b/crates/pinakes-plugin-api/src/lib.rs @@ -16,6 +16,7 @@ use thiserror::Error; pub mod manifest; pub mod types; pub mod ui_schema; +pub mod validation; pub mod wasm; pub use manifest::PluginManifest; diff --git a/crates/pinakes-plugin-api/src/validation.rs b/crates/pinakes-plugin-api/src/validation.rs new file mode 100644 index 0000000..d232f29 --- /dev/null +++ b/crates/pinakes-plugin-api/src/validation.rs @@ -0,0 +1,583 @@ +//! 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 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, .. } => { + for child in children { + Self::validate_element(child, errors); + } + }, + + UiElement::Grid { children, .. } => { + for child in children { + Self::validate_element(child, errors); + } + }, + + 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, .. } => { + if data.is_empty() { + errors.push("List 'data' source key must not be empty".to_string()); + } + }, + + // 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 { .. } => {}, + } + } + + 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}" + )); + } + }, + 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 == '-') + } + + fn is_reserved_route(route: &str) -> bool { + RESERVED_ROUTES.iter().any(|reserved| { + route == *reserved || route.starts_with(&format!("{reserved}/")) + }) + } +} + +#[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(), + } + } + + #[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()); + } +}