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 manifest;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod ui_schema;
|
pub mod ui_schema;
|
||||||
|
pub mod validation;
|
||||||
pub mod wasm;
|
pub mod wasm;
|
||||||
|
|
||||||
pub use manifest::PluginManifest;
|
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