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:
parent
9d93a527ca
commit
1c351c0f53
2 changed files with 627 additions and 29 deletions
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue