GUI plugins #9
2 changed files with 584 additions and 0 deletions
pinakes-plugin-api: schema validation for page and widget schemas
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I70480786fc8e4a86731560aaca6993ce6a6a6964
commit
21572541c3
|
|
@ -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;
|
||||
|
|
|
|||
583
crates/pinakes-plugin-api/src/validation.rs
Normal file
583
crates/pinakes-plugin-api/src/validation.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue