pinakes-plugin-api: consolidate reserved-route check; reject widget data-source refs

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I042ee31e95822f46520a618de8dcaf786a6a6964
This commit is contained in:
raf 2026-03-11 17:12:07 +03:00
commit dc4dc41670
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
2 changed files with 627 additions and 29 deletions

View file

@ -122,6 +122,10 @@ impl SchemaValidator {
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 {
@ -132,19 +136,9 @@ impl SchemaValidator {
/// 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, .. } => {
UiElement::Container { children, .. }
| UiElement::Grid { children, .. }
| UiElement::Flex { children, .. } => {
for child in children {
Self::validate_element(child, errors);
}
@ -206,10 +200,15 @@ impl SchemaValidator {
}
},
UiElement::List { data, .. } => {
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
@ -226,6 +225,66 @@ impl SchemaValidator {
}
}
/// 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,
@ -243,6 +302,12 @@ impl SchemaValidator {
"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() {
@ -264,7 +329,7 @@ impl SchemaValidator {
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn is_reserved_route(route: &str) -> bool {
pub(crate) fn is_reserved_route(route: &str) -> bool {
RESERVED_ROUTES.iter().any(|reserved| {
route == *reserved || route.starts_with(&format!("{reserved}/"))
})
@ -290,6 +355,7 @@ mod tests {
padding: None,
},
data_sources: HashMap::new(),
actions: HashMap::new(),
}
}
@ -580,4 +646,81 @@ mod tests {
};
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}"
);
}
}