GUI plugins #9

Merged
NotAShelf merged 46 commits from notashelf/push-mytsqvppsvxu into main 2026-03-12 16:53:43 +00:00
2 changed files with 584 additions and 0 deletions
Showing only changes of commit 21572541c3 - Show all commits

pinakes-plugin-api: schema validation for page and widget schemas

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I70480786fc8e4a86731560aaca6993ce6a6a6964
raf 2026-03-10 00:00:55 +03:00
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -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;

View file

@ -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<String>),
}
/// 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<String>) {
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<String>,
) {
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());
}
}