Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Id85a7e729b26af8eb028e19418a5a1706a6a6964
2223 lines
57 KiB
Rust
2223 lines
57 KiB
Rust
//! UI Schema Types for GUI Plugin System
|
|
//!
|
|
//! This module defines the declarative UI schema that plugins use to define
|
|
//! their user interfaces. The schema is JSON-based and rendered by the host UI
|
|
//! into Dioxus components.
|
|
//!
|
|
//! ## Security Model
|
|
//!
|
|
//! - No arbitrary code execution in UI - only declarative schema
|
|
//! - All text content is sanitized through ammonia before rendering
|
|
//! - URLs are validated against whitelist
|
|
//! - Component tree depth limited to prevent abuse
|
|
//! - Action execution goes through typed API layer
|
|
//!
|
|
//! ## Example Schema
|
|
//!
|
|
//! ```json
|
|
//! {
|
|
//! "id": "music-analyzer",
|
|
//! "title": "Music Analysis",
|
|
//! "route": "/plugins/music-analyzer",
|
|
//! "icon": "music",
|
|
//! "layout": {
|
|
//! "type": "split",
|
|
//! "sidebar": {
|
|
//! "type": "list",
|
|
//! "data": "playlists",
|
|
//! "item_template": { "type": "text", "content": "title" }
|
|
//! },
|
|
//! "main": {
|
|
//! "type": "data_table",
|
|
//! "columns": [
|
|
//! { "key": "title", "header": "Title" },
|
|
//! { "key": "duration", "header": "Duration" }
|
|
//! ],
|
|
//! "data": "tracks"
|
|
//! }
|
|
//! },
|
|
//! "data_sources": {
|
|
//! "playlists": { "type": "endpoint", "path": "/api/v1/collections" }
|
|
//! }
|
|
//! }
|
|
//!
|
|
//! Note: expression values are `Expression::Path` strings, not mustache
|
|
//! templates. A bare string like `"title"` resolves the `title` field in the
|
|
//! current item context. Nested fields use dotted segments: `"artist.name"`.
|
|
//! Array indices use the same notation: `"items.0.title"`.
|
|
//! ```
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
/// Maximum allowed depth for nested UI elements
|
|
pub const MAX_ELEMENT_DEPTH: usize = 10;
|
|
|
|
/// Maximum allowed width percentage
|
|
pub const MAX_WIDTH_PERCENT: u32 = 100;
|
|
|
|
/// Maximum allowed height in pixels
|
|
pub const MAX_HEIGHT_PX: u32 = 10000;
|
|
|
|
/// Schema validation and rendering errors
|
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
|
pub enum SchemaError {
|
|
#[error("validation failed: {0}")]
|
|
ValidationError(String),
|
|
|
|
#[error("invalid data source: {0}")]
|
|
InvalidDataSource(String),
|
|
|
|
#[error("invalid expression: {0}")]
|
|
InvalidExpression(String),
|
|
|
|
#[error("depth limit exceeded: max is {MAX_ELEMENT_DEPTH}")]
|
|
DepthLimitExceeded,
|
|
|
|
#[error("unknown field: {0}")]
|
|
UnknownField(String),
|
|
|
|
#[error("required field missing: {0}")]
|
|
MissingField(String),
|
|
}
|
|
|
|
/// Result type for schema operations
|
|
pub type SchemaResult<T> = Result<T, SchemaError>;
|
|
|
|
/// Root page definition for a plugin UI page
|
|
///
|
|
/// This is the top-level structure that defines a complete plugin page.
|
|
/// Each page has a unique route, title, and layout.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// use pinakes_plugin_api::ui_schema::{DataSource, UiElement, UiPage};
|
|
///
|
|
/// let page = UiPage {
|
|
/// id: "demo".to_string(),
|
|
/// title: "Demo Page".to_string(),
|
|
/// route: "/plugins/demo".to_string(),
|
|
/// icon: Some("star".to_string()),
|
|
/// root_element: UiElement::Container {
|
|
/// children: vec![],
|
|
/// gap: 16,
|
|
/// padding: None,
|
|
/// },
|
|
/// data_sources: Default::default(),
|
|
/// actions: Default::default(),
|
|
/// };
|
|
/// ```
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct UiPage {
|
|
/// Unique identifier for this page (must be unique across all plugins)
|
|
pub id: String,
|
|
|
|
/// Display title for the page
|
|
pub title: String,
|
|
|
|
/// URL route for this page (e.g., "/plugins/my-plugin/page1")
|
|
/// Must be unique across all pages
|
|
pub route: String,
|
|
|
|
/// Optional icon identifier (matches icon names in dioxus-free-icons)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub icon: Option<String>,
|
|
|
|
/// Root layout element defining the page structure
|
|
#[serde(rename = "layout")]
|
|
pub root_element: UiElement,
|
|
|
|
/// Named data sources available to this page
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
pub data_sources: HashMap<String, DataSource>,
|
|
|
|
/// Named actions available to this page (referenced by `ActionRef::Name`)
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
pub actions: HashMap<String, ActionDefinition>,
|
|
}
|
|
|
|
impl UiPage {
|
|
/// Validates the complete page definition
|
|
///
|
|
/// Checks:
|
|
/// - ID format (alphanumeric with dashes/underscores)
|
|
/// - Route format (must start with "/")
|
|
/// - Element tree depth
|
|
/// - Data source validity
|
|
/// - All data references in elements exist in `data_sources`
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `SchemaError::ValidationError` if validation fails
|
|
pub fn validate(&self) -> SchemaResult<()> {
|
|
validate_id(&self.id)?;
|
|
|
|
if !self.route.starts_with('/') {
|
|
return Err(SchemaError::ValidationError(
|
|
"Route must start with '/'".to_string(),
|
|
));
|
|
}
|
|
|
|
if crate::validation::SchemaValidator::is_reserved_route(&self.route) {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Route '{}' conflicts with a built-in app route",
|
|
self.route
|
|
)));
|
|
}
|
|
|
|
let depth = self.root_element.depth();
|
|
if depth > MAX_ELEMENT_DEPTH {
|
|
return Err(SchemaError::DepthLimitExceeded);
|
|
}
|
|
|
|
self.root_element.validate(self)?;
|
|
|
|
for (name, action) in &self.actions {
|
|
validate_id(name)?;
|
|
action.validate()?;
|
|
}
|
|
|
|
for (name, source) in &self.data_sources {
|
|
validate_id(name)?;
|
|
source.validate()?;
|
|
|
|
// Check that Transform sources reference existing data sources
|
|
if let DataSource::Transform { source_name, .. } = source
|
|
&& !self.data_sources.contains_key(source_name)
|
|
{
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"DataSource '{name}' references unknown source: {source_name}"
|
|
)));
|
|
}
|
|
}
|
|
|
|
// Detect cycles in Transform dependencies using DFS
|
|
self.validate_no_cycles()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Validates that there are no cycles in Transform data source dependencies
|
|
fn validate_no_cycles(&self) -> SchemaResult<()> {
|
|
let mut visited = std::collections::HashSet::new();
|
|
let mut stack = std::collections::HashSet::new();
|
|
|
|
for name in self.data_sources.keys() {
|
|
Self::dfs_check_cycles(self, name, &mut visited, &mut stack)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Helper function for cycle detection using DFS
|
|
fn dfs_check_cycles(
|
|
&self,
|
|
name: &str,
|
|
visited: &mut std::collections::HashSet<String>,
|
|
stack: &mut std::collections::HashSet<String>,
|
|
) -> SchemaResult<()> {
|
|
if stack.contains(name) {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Cycle detected in data source dependencies: {name}"
|
|
)));
|
|
}
|
|
if visited.contains(name) {
|
|
return Ok(());
|
|
}
|
|
|
|
stack.insert(name.to_string());
|
|
|
|
if let Some(DataSource::Transform { source_name, .. }) =
|
|
self.data_sources.get(name)
|
|
{
|
|
Self::dfs_check_cycles(self, source_name, visited, stack)?;
|
|
}
|
|
|
|
stack.remove(name);
|
|
visited.insert(name.to_string());
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns all data source names referenced by the layout
|
|
#[must_use]
|
|
pub fn referenced_data_sources(&self) -> Vec<String> {
|
|
let mut refs = Vec::new();
|
|
self.root_element.collect_data_refs(&mut refs);
|
|
refs.sort_unstable();
|
|
refs.dedup();
|
|
refs
|
|
}
|
|
}
|
|
|
|
/// A widget that plugins can inject into existing host pages.
|
|
///
|
|
/// Widgets differ from pages in that they are embedded at a specific
|
|
/// `target` location within built-in views rather than occupying a full page.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct UiWidget {
|
|
/// Unique identifier for this widget within the plugin
|
|
pub id: String,
|
|
|
|
/// Target injection point (matches `widget_location` constants)
|
|
pub target: String,
|
|
|
|
/// Content to render at the injection point
|
|
pub content: UiElement,
|
|
}
|
|
|
|
impl UiWidget {
|
|
/// Validates this widget definition
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `SchemaError::ValidationError` if validation fails
|
|
pub fn validate(&self) -> SchemaResult<()> {
|
|
if self.target.is_empty() {
|
|
return Err(SchemaError::ValidationError(
|
|
"Widget target cannot be empty".to_string(),
|
|
));
|
|
}
|
|
validate_id(&self.id)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// String constants for widget injection locations.
|
|
///
|
|
/// Use these with `UiWidget::target` in plugin manifests:
|
|
/// ```toml
|
|
/// [[ui.widgets]]
|
|
/// id = "my-widget"
|
|
/// target = "library_header"
|
|
/// ```
|
|
pub mod widget_location {
|
|
pub const LIBRARY_HEADER: &str = "library_header";
|
|
pub const LIBRARY_SIDEBAR: &str = "library_sidebar";
|
|
pub const DETAIL_PANEL: &str = "detail_panel";
|
|
pub const SEARCH_FILTERS: &str = "search_filters";
|
|
pub const SETTINGS_SECTION: &str = "settings_section";
|
|
}
|
|
|
|
/// Core UI element enum - the building block of all plugin UIs
|
|
///
|
|
/// Elements are categorized into groups:
|
|
/// - **Layout containers**: Structure the page (Container, Grid, Flex, Split,
|
|
/// Tabs)
|
|
/// - **Typography**: Text display (Heading, Text, Code)
|
|
/// - **Data display**: Show data (`DataTable`, Card, `MediaGrid`, List,
|
|
/// `DescriptionList`)
|
|
/// - **Interactive**: User input (Button, Form, Link, Progress, Badge)
|
|
/// - **Visualization**: Charts and graphs (Chart)
|
|
/// - **Conditional**: Dynamic rendering (Conditional, Loop)
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum UiElement {
|
|
/// Generic container with configurable layout
|
|
///
|
|
/// Use for grouping elements with gap and padding control.
|
|
Container {
|
|
/// Child elements
|
|
children: Vec<Self>,
|
|
|
|
/// Gap between children in pixels
|
|
#[serde(default = "default_gap")]
|
|
gap: u32,
|
|
|
|
/// Padding inside container (top, right, bottom, left)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
padding: Option<[u32; 4]>,
|
|
},
|
|
|
|
/// Grid layout with fixed columns
|
|
///
|
|
/// Arranges children in a grid with configurable column count.
|
|
Grid {
|
|
/// Child elements
|
|
children: Vec<Self>,
|
|
|
|
/// Number of columns (1-12)
|
|
#[serde(default = "default_grid_columns")]
|
|
columns: u8,
|
|
|
|
/// Gap between grid items in pixels
|
|
#[serde(default = "default_gap")]
|
|
gap: u32,
|
|
},
|
|
|
|
/// Horizontal or vertical flex layout
|
|
///
|
|
/// Use for linear arrangements with alignment control.
|
|
Flex {
|
|
/// Child elements
|
|
children: Vec<Self>,
|
|
|
|
/// Direction: "row" (horizontal) or "column" (vertical)
|
|
#[serde(default)]
|
|
direction: FlexDirection,
|
|
|
|
/// Main axis alignment
|
|
#[serde(default)]
|
|
justify: JustifyContent,
|
|
|
|
/// Cross axis alignment
|
|
#[serde(default)]
|
|
align: AlignItems,
|
|
|
|
/// Gap between items in pixels
|
|
#[serde(default = "default_gap")]
|
|
gap: u32,
|
|
|
|
/// Whether items should wrap to next line
|
|
#[serde(default)]
|
|
wrap: bool,
|
|
},
|
|
|
|
/// Split view with sidebar and main content
|
|
///
|
|
/// Classic sidebar + content layout with resizable divider.
|
|
Split {
|
|
/// Sidebar content
|
|
sidebar: Box<Self>,
|
|
|
|
/// Sidebar width in pixels
|
|
#[serde(default = "default_sidebar_width")]
|
|
sidebar_width: u32,
|
|
|
|
/// Main content area
|
|
main: Box<Self>,
|
|
},
|
|
|
|
/// Tabbed interface
|
|
///
|
|
/// Displays content in tabbed panels.
|
|
Tabs {
|
|
/// Tab definitions
|
|
tabs: Vec<TabDefinition>,
|
|
|
|
/// Initially active tab index
|
|
#[serde(default)]
|
|
default_tab: usize,
|
|
},
|
|
|
|
/// Section heading (h1-h6)
|
|
///
|
|
/// Semantic heading for page structure.
|
|
Heading {
|
|
/// Heading level 1-6
|
|
level: u8,
|
|
|
|
/// Heading text (can be static or expression)
|
|
#[serde(default)]
|
|
content: TextContent,
|
|
|
|
/// Optional ID for linking
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
id: Option<String>,
|
|
},
|
|
|
|
/// Paragraph text
|
|
///
|
|
/// Multi-line text with optional formatting.
|
|
Text {
|
|
/// Text content (can be static or expression)
|
|
#[serde(default)]
|
|
content: TextContent,
|
|
|
|
/// Text style variant
|
|
#[serde(default)]
|
|
variant: TextVariant,
|
|
|
|
/// Whether to allow HTML (sanitized) in content
|
|
#[serde(default)]
|
|
allow_html: bool,
|
|
},
|
|
|
|
/// Monospaced code block
|
|
///
|
|
/// Displays code with syntax highlighting support.
|
|
Code {
|
|
/// Code content
|
|
content: String,
|
|
|
|
/// Language for syntax highlighting (e.g., "rust", "json")
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
language: Option<String>,
|
|
|
|
/// Whether to show line numbers
|
|
#[serde(default)]
|
|
show_line_numbers: bool,
|
|
},
|
|
|
|
/// Data table with sortable columns
|
|
///
|
|
/// Displays tabular data with sorting, filtering, and pagination.
|
|
DataTable {
|
|
/// Column definitions
|
|
columns: Vec<ColumnDef>,
|
|
|
|
/// Data source reference (key into `page.data_sources`)
|
|
data: String,
|
|
|
|
/// Whether to enable sorting
|
|
#[serde(default = "default_true")]
|
|
sortable: bool,
|
|
|
|
/// Whether to enable filtering
|
|
#[serde(default)]
|
|
filterable: bool,
|
|
|
|
/// Items per page (0 = no pagination)
|
|
#[serde(default)]
|
|
page_size: usize,
|
|
|
|
/// Row actions (displayed as buttons per row)
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
row_actions: Vec<RowAction>,
|
|
},
|
|
|
|
/// Card component for content grouping
|
|
///
|
|
/// Displays content in a card with optional header and footer.
|
|
Card {
|
|
/// Card title
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
title: Option<String>,
|
|
|
|
/// Main content elements
|
|
content: Vec<Self>,
|
|
|
|
/// Footer elements
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
footer: Vec<Self>,
|
|
},
|
|
|
|
/// Media grid for images/videos
|
|
///
|
|
/// Displays media items in a responsive grid.
|
|
MediaGrid {
|
|
/// Data source reference containing media items
|
|
data: String,
|
|
|
|
/// Number of columns (1-12)
|
|
#[serde(default = "default_grid_columns")]
|
|
columns: u8,
|
|
|
|
/// Gap between items in pixels
|
|
#[serde(default = "default_gap")]
|
|
gap: u32,
|
|
},
|
|
|
|
/// List component with item template
|
|
///
|
|
/// Renders a list by applying a template to each item.
|
|
List {
|
|
/// Data source reference
|
|
data: String,
|
|
|
|
/// Template for each list item
|
|
item_template: Box<Self>,
|
|
|
|
/// Whether to show dividers between items
|
|
#[serde(default = "default_true")]
|
|
dividers: bool,
|
|
},
|
|
|
|
/// Description list (key-value pairs)
|
|
///
|
|
/// Displays key-value pairs, ideal for metadata.
|
|
DescriptionList {
|
|
/// Data source reference containing key-value pairs
|
|
data: String,
|
|
|
|
/// Whether to display horizontally
|
|
#[serde(default)]
|
|
horizontal: bool,
|
|
},
|
|
|
|
/// Button component
|
|
///
|
|
/// Triggers actions when clicked.
|
|
Button {
|
|
/// Button label
|
|
label: String,
|
|
|
|
/// Button variant/style
|
|
#[serde(default)]
|
|
variant: ButtonVariant,
|
|
|
|
/// Action to execute on click
|
|
action: ActionRef,
|
|
|
|
/// Whether button is disabled
|
|
#[serde(default)]
|
|
disabled: bool,
|
|
},
|
|
|
|
/// Form with fields
|
|
///
|
|
/// Input form with validation and submission.
|
|
Form {
|
|
/// Form field definitions
|
|
fields: Vec<FormField>,
|
|
|
|
/// Submit button label
|
|
#[serde(default = "default_submit_label")]
|
|
submit_label: String,
|
|
|
|
/// Action to execute on submit
|
|
submit_action: ActionRef,
|
|
|
|
/// Cancel button label (None = no cancel button)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
cancel_label: Option<String>,
|
|
},
|
|
|
|
/// Link component
|
|
///
|
|
/// Navigates to internal or external URLs.
|
|
Link {
|
|
/// Link text
|
|
text: String,
|
|
|
|
/// Target URL
|
|
href: String,
|
|
|
|
/// Whether to open in new tab
|
|
#[serde(default)]
|
|
external: bool,
|
|
},
|
|
|
|
/// Progress bar or indicator
|
|
///
|
|
/// Shows progress of an operation.
|
|
Progress {
|
|
/// Current value expression
|
|
value: Expression,
|
|
|
|
/// Maximum value
|
|
#[serde(default = "default_progress_max")]
|
|
max: f64,
|
|
|
|
/// Whether to show percentage text
|
|
#[serde(default = "default_true")]
|
|
show_percentage: bool,
|
|
},
|
|
|
|
/// Badge/chip component
|
|
///
|
|
/// Small label for status, tags, etc.
|
|
Badge {
|
|
/// Badge text
|
|
text: String,
|
|
|
|
/// Badge color variant
|
|
#[serde(default)]
|
|
variant: BadgeVariant,
|
|
},
|
|
|
|
/// Chart visualization
|
|
///
|
|
/// Displays data as charts (bar, line, pie, etc.).
|
|
Chart {
|
|
/// Chart type
|
|
chart_type: ChartType,
|
|
|
|
/// Data source reference
|
|
data: String,
|
|
|
|
/// Chart title
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
title: Option<String>,
|
|
|
|
/// X-axis label
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
x_axis_label: Option<String>,
|
|
|
|
/// Y-axis label
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
y_axis_label: Option<String>,
|
|
|
|
/// Chart height in pixels
|
|
#[serde(default = "default_chart_height")]
|
|
height: u32,
|
|
},
|
|
|
|
/// Conditional rendering
|
|
///
|
|
/// Shows different content based on a condition.
|
|
Conditional {
|
|
/// Condition expression (evaluates to boolean)
|
|
condition: Expression,
|
|
|
|
/// Element to render when condition is true
|
|
then: Box<Self>,
|
|
|
|
/// Optional element to render when condition is false
|
|
#[serde(rename = "else", skip_serializing_if = "Option::is_none")]
|
|
else_element: Option<Box<Self>>,
|
|
},
|
|
|
|
/// Loop/repeat over data
|
|
///
|
|
/// Renders a template for each item in a collection.
|
|
Loop {
|
|
/// Data source reference (must be an array)
|
|
data: String,
|
|
|
|
/// Template for each iteration
|
|
template: Box<Self>,
|
|
|
|
/// Content to show when data is empty
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
empty: Option<Box<Self>>,
|
|
},
|
|
}
|
|
|
|
impl UiElement {
|
|
/// Calculates the depth of this element tree
|
|
pub fn depth(&self) -> usize {
|
|
1 + match self {
|
|
Self::Container { children, .. }
|
|
| Self::Grid { children, .. }
|
|
| Self::Flex { children, .. } => {
|
|
children.iter().map(Self::depth).max().unwrap_or(0)
|
|
},
|
|
Self::Split { sidebar, main, .. } => sidebar.depth().max(main.depth()),
|
|
Self::Tabs { tabs, .. } => {
|
|
tabs.iter().map(|t| t.content.depth()).max().unwrap_or(0)
|
|
},
|
|
Self::Card {
|
|
content, footer, ..
|
|
} => {
|
|
content
|
|
.iter()
|
|
.chain(footer.iter())
|
|
.map(Self::depth)
|
|
.max()
|
|
.unwrap_or(0)
|
|
},
|
|
Self::List { item_template, .. } => item_template.depth(),
|
|
Self::Conditional {
|
|
then, else_element, ..
|
|
} => {
|
|
let else_depth = else_element.as_ref().map_or(0, |e| e.depth());
|
|
then.depth().max(else_depth)
|
|
},
|
|
Self::Loop {
|
|
template, empty, ..
|
|
} => {
|
|
let empty_depth = empty.as_ref().map_or(0, |e| e.depth());
|
|
template.depth().max(empty_depth)
|
|
},
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
/// Validates this element and its children
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `SchemaError` if validation fails
|
|
pub fn validate(&self, page: &UiPage) -> SchemaResult<()> {
|
|
match self {
|
|
Self::Container { children, .. } | Self::Flex { children, .. } => {
|
|
for child in children {
|
|
child.validate(page)?;
|
|
}
|
|
},
|
|
Self::Grid {
|
|
children, columns, ..
|
|
} => {
|
|
if *columns == 0 || *columns > 12 {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Grid columns must be between 1 and 12, got {columns}"
|
|
)));
|
|
}
|
|
for child in children {
|
|
child.validate(page)?;
|
|
}
|
|
},
|
|
Self::Split { sidebar, main, .. } => {
|
|
sidebar.validate(page)?;
|
|
main.validate(page)?;
|
|
},
|
|
Self::Tabs {
|
|
tabs, default_tab, ..
|
|
} => {
|
|
if tabs.is_empty() {
|
|
return Err(SchemaError::ValidationError(
|
|
"Tabs array must contain at least one tab".to_string(),
|
|
));
|
|
}
|
|
if *default_tab >= tabs.len() {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Default tab index {default_tab} out of range (max: {})",
|
|
tabs.len().saturating_sub(1)
|
|
)));
|
|
}
|
|
for tab in tabs {
|
|
tab.content.validate(page)?;
|
|
}
|
|
},
|
|
Self::Heading { level, .. } if *level == 0 || *level > 6 => {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Heading level must be between 1 and 6, got {level}"
|
|
)));
|
|
},
|
|
Self::DataTable { columns, data, .. } => {
|
|
if columns.is_empty() {
|
|
return Err(SchemaError::ValidationError(
|
|
"DataTable must have at least one column".to_string(),
|
|
));
|
|
}
|
|
if !page.data_sources.contains_key(data) {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"DataTable references unknown data source: {data}"
|
|
)));
|
|
}
|
|
},
|
|
Self::Card {
|
|
content, footer, ..
|
|
} => {
|
|
for child in content {
|
|
child.validate(page)?;
|
|
}
|
|
for child in footer {
|
|
child.validate(page)?;
|
|
}
|
|
},
|
|
Self::MediaGrid { columns, data, .. } => {
|
|
if *columns == 0 || *columns > 12 {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"MediaGrid columns must be between 1 and 12, got {columns}"
|
|
)));
|
|
}
|
|
if !page.data_sources.contains_key(data) {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"MediaGrid references unknown data source: {data}"
|
|
)));
|
|
}
|
|
},
|
|
Self::Chart { data, .. } if !page.data_sources.contains_key(data) => {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Chart references unknown data source: {data}"
|
|
)));
|
|
},
|
|
Self::Conditional {
|
|
then, else_element, ..
|
|
} => {
|
|
then.validate(page)?;
|
|
if let Some(else_el) = else_element {
|
|
else_el.validate(page)?;
|
|
}
|
|
},
|
|
Self::List {
|
|
data,
|
|
item_template,
|
|
..
|
|
} => {
|
|
if !page.data_sources.contains_key(data) {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"List references unknown data source: {data}"
|
|
)));
|
|
}
|
|
item_template.validate(page)?;
|
|
},
|
|
Self::DescriptionList { data, .. }
|
|
if !page.data_sources.contains_key(data) =>
|
|
{
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"DescriptionList references unknown data source: {data}"
|
|
)));
|
|
},
|
|
Self::Loop {
|
|
template,
|
|
empty,
|
|
data,
|
|
..
|
|
} => {
|
|
if !page.data_sources.contains_key(data) {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Loop references unknown data source: {data}"
|
|
)));
|
|
}
|
|
template.validate(page)?;
|
|
if let Some(empty_el) = empty {
|
|
empty_el.validate(page)?;
|
|
}
|
|
},
|
|
Self::Button { action, .. } => {
|
|
action.validate()?;
|
|
},
|
|
Self::Link { href, .. } if !is_safe_href(href) => {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Link href has a disallowed scheme (must be '/', 'http://', or 'https://'): {href}"
|
|
)));
|
|
},
|
|
Self::Form {
|
|
fields,
|
|
submit_action,
|
|
..
|
|
} => {
|
|
if fields.is_empty() {
|
|
return Err(SchemaError::ValidationError(
|
|
"Form must have at least one field".to_string(),
|
|
));
|
|
}
|
|
for field in fields {
|
|
validate_id(&field.id)?;
|
|
if field.label.is_empty() {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Form field '{}' must have a label",
|
|
field.id
|
|
)));
|
|
}
|
|
}
|
|
submit_action.validate()?;
|
|
},
|
|
_ => {},
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Collects all data source references from this element
|
|
fn collect_data_refs(&self, refs: &mut Vec<String>) {
|
|
match self {
|
|
Self::Container { children, .. }
|
|
| Self::Grid { children, .. }
|
|
| Self::Flex { children, .. } => {
|
|
for child in children {
|
|
child.collect_data_refs(refs);
|
|
}
|
|
},
|
|
Self::Split { sidebar, main, .. } => {
|
|
sidebar.collect_data_refs(refs);
|
|
main.collect_data_refs(refs);
|
|
},
|
|
Self::Tabs { tabs, .. } => {
|
|
for tab in tabs {
|
|
tab.content.collect_data_refs(refs);
|
|
}
|
|
},
|
|
Self::Card {
|
|
content, footer, ..
|
|
} => {
|
|
for child in content {
|
|
child.collect_data_refs(refs);
|
|
}
|
|
for child in footer {
|
|
child.collect_data_refs(refs);
|
|
}
|
|
},
|
|
Self::DataTable { data, .. }
|
|
| Self::MediaGrid { data, .. }
|
|
| Self::DescriptionList { data, .. }
|
|
| Self::Chart { data, .. } => {
|
|
refs.push(data.clone());
|
|
},
|
|
Self::List {
|
|
data,
|
|
item_template,
|
|
..
|
|
} => {
|
|
refs.push(data.clone());
|
|
item_template.collect_data_refs(refs);
|
|
},
|
|
Self::Loop {
|
|
data,
|
|
template,
|
|
empty,
|
|
..
|
|
} => {
|
|
refs.push(data.clone());
|
|
template.collect_data_refs(refs);
|
|
if let Some(empty_el) = empty {
|
|
empty_el.collect_data_refs(refs);
|
|
}
|
|
},
|
|
Self::Conditional {
|
|
then, else_element, ..
|
|
} => {
|
|
then.collect_data_refs(refs);
|
|
if let Some(else_el) = else_element {
|
|
else_el.collect_data_refs(refs);
|
|
}
|
|
},
|
|
_ => {},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Flex direction for Flex layout
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum FlexDirection {
|
|
/// Horizontal layout (left to right)
|
|
#[default]
|
|
Row,
|
|
/// Vertical layout (top to bottom)
|
|
Column,
|
|
}
|
|
|
|
/// Justify content alignment for Flex layout
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum JustifyContent {
|
|
/// Items packed to start
|
|
#[default]
|
|
FlexStart,
|
|
/// Items packed to end
|
|
FlexEnd,
|
|
/// Items centered
|
|
Center,
|
|
/// Items evenly distributed with space between
|
|
SpaceBetween,
|
|
/// Items evenly distributed with space around
|
|
SpaceAround,
|
|
/// Items evenly distributed with equal space
|
|
SpaceEvenly,
|
|
}
|
|
|
|
/// Align items for Flex layout (cross-axis alignment)
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum AlignItems {
|
|
/// Items aligned to start
|
|
#[default]
|
|
FlexStart,
|
|
/// Items aligned to end
|
|
FlexEnd,
|
|
/// Items centered
|
|
Center,
|
|
/// Items stretched to fill
|
|
Stretch,
|
|
/// Items aligned to baseline
|
|
Baseline,
|
|
}
|
|
|
|
/// Text content can be static or dynamic
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(untagged)]
|
|
pub enum TextContent {
|
|
/// Static text string
|
|
Static(String),
|
|
|
|
/// Dynamic expression that evaluates to text
|
|
Expression(Expression),
|
|
|
|
/// Default (empty text)
|
|
#[default]
|
|
Empty,
|
|
}
|
|
|
|
impl TextContent {
|
|
/// Returns true if content is empty
|
|
#[must_use]
|
|
pub const fn is_empty(&self) -> bool {
|
|
matches!(self, Self::Empty)
|
|
}
|
|
}
|
|
|
|
impl From<String> for TextContent {
|
|
fn from(s: String) -> Self {
|
|
Self::Static(s)
|
|
}
|
|
}
|
|
|
|
impl From<&str> for TextContent {
|
|
fn from(s: &str) -> Self {
|
|
Self::Static(s.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<Expression> for TextContent {
|
|
fn from(expr: Expression) -> Self {
|
|
Self::Expression(expr)
|
|
}
|
|
}
|
|
|
|
/// Text style variants
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum TextVariant {
|
|
/// Default body text
|
|
#[default]
|
|
Body,
|
|
/// Secondary/muted text
|
|
Secondary,
|
|
/// Success text (green)
|
|
Success,
|
|
/// Warning text (yellow/orange)
|
|
Warning,
|
|
/// Error text (red)
|
|
Error,
|
|
/// Bold text
|
|
Bold,
|
|
/// Italic text
|
|
Italic,
|
|
/// Small/caption text
|
|
Small,
|
|
/// Large/lead text
|
|
Large,
|
|
}
|
|
|
|
/// Column definition for `DataTable`
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct ColumnDef {
|
|
/// Data key for this column
|
|
pub key: String,
|
|
|
|
/// Display header text
|
|
pub header: String,
|
|
|
|
/// Column width (e.g., "100px", "20%", or "auto")
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub width: Option<String>,
|
|
|
|
/// Whether column is sortable
|
|
#[serde(default = "default_true")]
|
|
pub sortable: bool,
|
|
|
|
/// Data type for formatting (affects sorting and display)
|
|
#[serde(default)]
|
|
pub data_type: ColumnDataType,
|
|
|
|
/// Custom format string (e.g., for dates: "YYYY-MM-DD")
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub format: Option<String>,
|
|
}
|
|
|
|
/// Row action for `DataTable`
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct RowAction {
|
|
/// Action identifier (unique within this table)
|
|
pub id: String,
|
|
|
|
/// Display label
|
|
pub label: String,
|
|
|
|
/// Action to execute
|
|
pub action: ActionRef,
|
|
|
|
/// Button variant
|
|
#[serde(default)]
|
|
pub variant: ButtonVariant,
|
|
}
|
|
|
|
/// Data type for column formatting
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ColumnDataType {
|
|
/// Generic text
|
|
#[default]
|
|
Text,
|
|
/// Numeric value
|
|
Number,
|
|
/// Date/time
|
|
DateTime,
|
|
/// Boolean (shows as checkmark/x)
|
|
Boolean,
|
|
/// File size (auto-formatted)
|
|
FileSize,
|
|
/// Duration in seconds (formatted as HH:MM:SS)
|
|
Duration,
|
|
}
|
|
|
|
/// Tab definition for Tabs component
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct TabDefinition {
|
|
/// Tab label
|
|
pub label: String,
|
|
|
|
/// Tab icon (optional)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub icon: Option<String>,
|
|
|
|
/// Tab content
|
|
pub content: UiElement,
|
|
}
|
|
|
|
/// Button style variants
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ButtonVariant {
|
|
/// Primary action button (filled)
|
|
#[default]
|
|
Primary,
|
|
/// Secondary button (outlined)
|
|
Secondary,
|
|
/// Tertiary button (text only)
|
|
Tertiary,
|
|
/// Danger/destructive action
|
|
Danger,
|
|
/// Success/confirm action
|
|
Success,
|
|
/// Ghost/invisible button
|
|
Ghost,
|
|
}
|
|
|
|
/// Form field definition
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct FormField {
|
|
/// Field identifier (form data key)
|
|
pub id: String,
|
|
|
|
/// Field label
|
|
pub label: String,
|
|
|
|
/// Field type
|
|
#[serde(rename = "type")]
|
|
pub field_type: FieldType,
|
|
|
|
/// Whether field is required
|
|
#[serde(default)]
|
|
pub required: bool,
|
|
|
|
/// Placeholder text (for text inputs)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub placeholder: Option<String>,
|
|
|
|
/// Default value
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub default_value: Option<serde_json::Value>,
|
|
|
|
/// Validation rules
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub validation: Vec<ValidationRule>,
|
|
|
|
/// Help text displayed below field
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub help_text: Option<String>,
|
|
}
|
|
|
|
/// Form field types
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum FieldType {
|
|
/// Single-line text input
|
|
Text {
|
|
/// Maximum length
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
max_length: Option<usize>,
|
|
},
|
|
|
|
/// Multi-line text area
|
|
Textarea {
|
|
/// Number of rows
|
|
#[serde(default = "default_textarea_rows")]
|
|
rows: u32,
|
|
},
|
|
|
|
/// Email input with validation
|
|
Email,
|
|
|
|
/// URL input with validation
|
|
Url,
|
|
|
|
/// Number input
|
|
Number {
|
|
/// Minimum value
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
min: Option<f64>,
|
|
/// Maximum value
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
max: Option<f64>,
|
|
/// Step increment
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
step: Option<f64>,
|
|
},
|
|
|
|
/// Toggle switch
|
|
Switch,
|
|
|
|
/// Checkbox
|
|
Checkbox {
|
|
/// Checkbox label (separate from field label)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
checkbox_label: Option<String>,
|
|
},
|
|
|
|
/// Select dropdown
|
|
Select {
|
|
/// Available options
|
|
options: Vec<SelectOption>,
|
|
/// Whether multiple selection is allowed
|
|
#[serde(default)]
|
|
multiple: bool,
|
|
},
|
|
|
|
/// Radio button group
|
|
Radio {
|
|
/// Available options
|
|
options: Vec<SelectOption>,
|
|
},
|
|
|
|
/// Date picker
|
|
Date,
|
|
|
|
/// `DateTime` picker
|
|
DateTime,
|
|
|
|
/// File upload
|
|
File {
|
|
/// Accepted MIME types (e.g., [`"image/*"`, `"application/pdf"`])
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
accept: Option<Vec<String>>,
|
|
/// Maximum file size in bytes
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
max_size: Option<u64>,
|
|
/// Whether multiple files allowed
|
|
#[serde(default)]
|
|
multiple: bool,
|
|
},
|
|
}
|
|
|
|
/// Select/Radio option
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct SelectOption {
|
|
/// Option value (stored in form data)
|
|
pub value: String,
|
|
/// Display label
|
|
pub label: String,
|
|
}
|
|
|
|
/// Validation rules for form fields
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum ValidationRule {
|
|
/// Minimum length (for text)
|
|
MinLength { value: usize },
|
|
/// Maximum length (for text)
|
|
MaxLength { value: usize },
|
|
/// Pattern match (regex)
|
|
Pattern { regex: String },
|
|
/// Custom validation message
|
|
Custom { message: String },
|
|
}
|
|
|
|
/// Badge color variants
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum BadgeVariant {
|
|
/// Default/neutral
|
|
#[default]
|
|
Default,
|
|
/// Primary color
|
|
Primary,
|
|
/// Secondary color
|
|
Secondary,
|
|
/// Success/green
|
|
Success,
|
|
/// Warning/yellow
|
|
Warning,
|
|
/// Error/red
|
|
Error,
|
|
/// Info/blue
|
|
Info,
|
|
}
|
|
|
|
/// Chart types
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ChartType {
|
|
/// Bar chart
|
|
Bar,
|
|
/// Line chart
|
|
Line,
|
|
/// Pie/donut chart
|
|
Pie,
|
|
/// Area chart
|
|
Area,
|
|
/// Scatter plot
|
|
Scatter,
|
|
}
|
|
|
|
/// Client-side action types that do not require an HTTP call.
|
|
///
|
|
/// Used as `{"action": "<kind>", ...}` in JSON.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(tag = "action", rename_all = "snake_case")]
|
|
pub enum SpecialAction {
|
|
/// Trigger a data refresh (re-runs all data sources for the current page).
|
|
Refresh,
|
|
/// Navigate to a different route.
|
|
Navigate {
|
|
/// Target route path (must start with `/`)
|
|
to: String,
|
|
},
|
|
/// Emit a named event to the server-side plugin event bus.
|
|
Emit {
|
|
/// Event name
|
|
event: String,
|
|
/// Optional payload (any JSON value)
|
|
#[serde(default)]
|
|
payload: serde_json::Value,
|
|
},
|
|
/// Update a local state key (resolved against the current data context).
|
|
UpdateState {
|
|
/// State key name
|
|
key: String,
|
|
/// Expression whose value is stored at `key`
|
|
value: Expression,
|
|
},
|
|
/// Open a modal overlay containing the given element.
|
|
OpenModal {
|
|
/// Element to render inside the modal
|
|
content: Box<UiElement>,
|
|
},
|
|
/// Close the currently open modal overlay.
|
|
CloseModal,
|
|
}
|
|
|
|
/// Action reference - identifies an action to execute
|
|
///
|
|
/// Deserialization order for `#[serde(untagged)]`:
|
|
/// 1. `Special` - JSON objects with an `"action"` string key
|
|
/// 2. `Inline` - JSON objects with a `"path"` key
|
|
/// 3. `Name` - bare JSON strings
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(untagged)]
|
|
pub enum ActionRef {
|
|
/// Client-side special action (no HTTP call required)
|
|
Special(SpecialAction),
|
|
|
|
/// Inline action definition (HTTP call)
|
|
Inline(ActionDefinition),
|
|
|
|
/// Simple action name (references page.actions)
|
|
Name(String),
|
|
}
|
|
|
|
impl ActionRef {
|
|
/// Validates the action reference
|
|
///
|
|
/// For inline actions, validates the `ActionDefinition`.
|
|
/// For named actions, validates the name is a valid identifier.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `SchemaError::ValidationError` if validation fails.
|
|
pub fn validate(&self) -> SchemaResult<()> {
|
|
match self {
|
|
Self::Special(s) => {
|
|
match s {
|
|
SpecialAction::Navigate { to } if to.is_empty() => {
|
|
return Err(SchemaError::ValidationError(
|
|
"Navigate.to cannot be empty".to_string(),
|
|
));
|
|
},
|
|
SpecialAction::UpdateState { key, .. } if key.is_empty() => {
|
|
return Err(SchemaError::ValidationError(
|
|
"UpdateState.key cannot be empty".to_string(),
|
|
));
|
|
},
|
|
SpecialAction::Emit { event, .. } if event.is_empty() => {
|
|
return Err(SchemaError::ValidationError(
|
|
"Emit.event cannot be empty".to_string(),
|
|
));
|
|
},
|
|
_ => {},
|
|
}
|
|
},
|
|
Self::Name(name) => {
|
|
if name.is_empty() {
|
|
return Err(SchemaError::ValidationError(
|
|
"Action name cannot be empty".to_string(),
|
|
));
|
|
}
|
|
validate_id(name)?;
|
|
},
|
|
Self::Inline(action) => {
|
|
action.validate()?;
|
|
},
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Action definition
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct ActionDefinition {
|
|
/// HTTP method
|
|
#[serde(default = "default_http_method")]
|
|
pub method: HttpMethod,
|
|
|
|
/// API endpoint path (must start with '/')
|
|
pub path: String,
|
|
|
|
/// Action parameters (merged with form data on submit)
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
pub params: HashMap<String, serde_json::Value>,
|
|
|
|
/// Success message
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub success_message: Option<String>,
|
|
|
|
/// Error message
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error_message: Option<String>,
|
|
|
|
/// Navigation after success (route to navigate to)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub navigate_to: Option<String>,
|
|
}
|
|
|
|
impl ActionDefinition {
|
|
/// Validates the action definition
|
|
///
|
|
/// Checks:
|
|
/// - Path is non-empty and starts with '/'
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `SchemaError::ValidationError` if validation fails.
|
|
pub fn validate(&self) -> SchemaResult<()> {
|
|
if self.path.is_empty() {
|
|
return Err(SchemaError::ValidationError(
|
|
"Action path cannot be empty".to_string(),
|
|
));
|
|
}
|
|
if !self.path.starts_with('/') {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Action path must start with '/': {}",
|
|
self.path
|
|
)));
|
|
}
|
|
if !self.path.starts_with("/api/") {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Action path must start with '/api/': {}",
|
|
self.path
|
|
)));
|
|
}
|
|
if self.path.contains("..") {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"Action path contains invalid traversal sequence: {}",
|
|
self.path
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Default for ActionDefinition {
|
|
fn default() -> Self {
|
|
Self {
|
|
method: default_http_method(),
|
|
path: String::new(),
|
|
params: HashMap::new(),
|
|
success_message: None,
|
|
error_message: None,
|
|
navigate_to: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// HTTP methods for actions
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "UPPERCASE")]
|
|
pub enum HttpMethod {
|
|
#[default]
|
|
Get,
|
|
Post,
|
|
Put,
|
|
Patch,
|
|
Delete,
|
|
}
|
|
|
|
/// Data source for dynamic content
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum DataSource {
|
|
/// API endpoint
|
|
Endpoint {
|
|
/// HTTP method
|
|
#[serde(default = "default_http_method")]
|
|
method: HttpMethod,
|
|
|
|
/// API path (relative to base URL)
|
|
path: String,
|
|
|
|
/// Query parameters
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
params: HashMap<String, Expression>,
|
|
|
|
/// Polling interval in seconds (0 = no polling)
|
|
#[serde(default)]
|
|
poll_interval: u64,
|
|
|
|
/// Transform expression applied to response
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
transform: Option<Expression>,
|
|
},
|
|
|
|
/// Transform of another data source
|
|
Transform {
|
|
/// Source data reference
|
|
#[serde(rename = "source")]
|
|
source_name: String,
|
|
|
|
/// Transform expression
|
|
expression: Expression,
|
|
},
|
|
|
|
/// Static data
|
|
Static {
|
|
/// Static JSON value
|
|
value: serde_json::Value,
|
|
},
|
|
}
|
|
|
|
impl DataSource {
|
|
/// Validates this data source
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `SchemaError::InvalidDataSource` if validation fails
|
|
pub fn validate(&self) -> SchemaResult<()> {
|
|
match self {
|
|
Self::Endpoint { path, .. } => {
|
|
if !path.starts_with('/') {
|
|
return Err(SchemaError::InvalidDataSource(format!(
|
|
"Endpoint path must start with '/': {path}"
|
|
)));
|
|
}
|
|
if !path.starts_with("/api/") {
|
|
return Err(SchemaError::InvalidDataSource(format!(
|
|
"Endpoint path must start with '/api/': {path}"
|
|
)));
|
|
}
|
|
if path.contains("..") {
|
|
return Err(SchemaError::InvalidDataSource(format!(
|
|
"Endpoint path contains invalid traversal sequence: {path}"
|
|
)));
|
|
}
|
|
},
|
|
Self::Transform { source_name, .. } => {
|
|
validate_id(source_name)?;
|
|
},
|
|
Self::Static { .. } => {},
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Expression for dynamic value evaluation
|
|
///
|
|
/// Expressions use JSONPath-like syntax for data access.
|
|
///
|
|
/// ## JSON representation (serde untagged; order matters)
|
|
///
|
|
/// Variants are tried in declaration order during deserialization:
|
|
///
|
|
/// | JSON shape | Deserializes as |
|
|
/// |---------------------------------------------------|-----------------|
|
|
/// | `"users.0.name"` (string) | `Path` |
|
|
/// | `{"left":…,"op":"eq","right":…}` (object) | `Operation` |
|
|
/// | `{"function":"len","args":[…]}` (object) | `Call` |
|
|
/// | `42`, `true`, `null`, `[…]`, `{other fields}` … | `Literal` |
|
|
///
|
|
/// `Literal` is intentionally last so that the more specific variants take
|
|
/// priority. A bare JSON string is always a **path reference**; to embed a
|
|
/// literal string value use `DataSource::Static` or a `Call` expression.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(untagged)]
|
|
pub enum Expression {
|
|
/// Data path reference: a dotted key sequence walked against the context.
|
|
///
|
|
/// e.g. `"user.name"` resolves to `ctx["user"]["name"]`; `"items.0"` resolves
|
|
/// to the first element.
|
|
Path(String),
|
|
|
|
/// Binary operation applied to two sub-expressions.
|
|
Operation {
|
|
/// Left operand
|
|
left: Box<Self>,
|
|
/// Operator
|
|
op: Operator,
|
|
/// Right operand
|
|
right: Box<Self>,
|
|
},
|
|
|
|
/// Built-in function call.
|
|
///
|
|
/// e.g. `{"function": "len", "args": ["tags"]}` returns the count of items
|
|
/// in the `tags` data source.
|
|
Call {
|
|
/// Function name (see built-in function table in docs)
|
|
function: String,
|
|
/// Positional arguments, each an `Expression`
|
|
args: Vec<Self>,
|
|
},
|
|
|
|
/// Literal JSON value: a constant that is returned unchanged.
|
|
///
|
|
/// Matches numbers, booleans, null, arrays, and objects that do not match
|
|
/// the `Operation` or `Call` shapes above.
|
|
Literal(serde_json::Value),
|
|
}
|
|
|
|
impl Default for Expression {
|
|
fn default() -> Self {
|
|
Self::Literal(serde_json::Value::Null)
|
|
}
|
|
}
|
|
|
|
/// Binary operators
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Operator {
|
|
/// Equal
|
|
Eq,
|
|
/// Not equal
|
|
Ne,
|
|
/// Greater than
|
|
Gt,
|
|
/// Greater than or equal
|
|
Gte,
|
|
/// Less than
|
|
Lt,
|
|
/// Less than or equal
|
|
Lte,
|
|
/// Logical and
|
|
And,
|
|
/// Logical or
|
|
Or,
|
|
/// String concatenation
|
|
Concat,
|
|
/// Addition
|
|
Add,
|
|
/// Subtraction
|
|
Sub,
|
|
/// Multiplication
|
|
Mul,
|
|
/// Division
|
|
Div,
|
|
}
|
|
|
|
// Helper functions for defaults
|
|
|
|
const fn default_gap() -> u32 {
|
|
16
|
|
}
|
|
|
|
const fn default_sidebar_width() -> u32 {
|
|
280
|
|
}
|
|
|
|
const fn default_grid_columns() -> u8 {
|
|
4
|
|
}
|
|
|
|
const fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
const fn default_textarea_rows() -> u32 {
|
|
3
|
|
}
|
|
|
|
const fn default_chart_height() -> u32 {
|
|
300
|
|
}
|
|
|
|
const fn default_progress_max() -> f64 {
|
|
100.0
|
|
}
|
|
|
|
fn default_submit_label() -> String {
|
|
"Submit".to_string()
|
|
}
|
|
|
|
const fn default_http_method() -> HttpMethod {
|
|
HttpMethod::Get
|
|
}
|
|
|
|
/// Returns `true` if `href` uses a scheme safe to render in an anchor element.
|
|
///
|
|
/// Allows relative paths (`/`), plain `http://`, and `https://`. Rejects
|
|
/// `javascript:`, `data:`, `vbscript:`, and any other scheme that could be
|
|
/// used for script injection or data exfiltration.
|
|
#[must_use]
|
|
pub fn is_safe_href(href: &str) -> bool {
|
|
href.starts_with('/')
|
|
|| href.starts_with("https://")
|
|
|| href.starts_with("http://")
|
|
}
|
|
|
|
/// Validates an identifier string
|
|
///
|
|
/// IDs must:
|
|
/// - Start with a letter
|
|
/// - Contain only alphanumeric characters, underscores, and dashes
|
|
/// - Be between 1 and 64 characters
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `SchemaError::ValidationError` if the ID is invalid
|
|
fn validate_id(id: &str) -> SchemaResult<()> {
|
|
if id.is_empty() {
|
|
return Err(SchemaError::ValidationError(
|
|
"ID cannot be empty".to_string(),
|
|
));
|
|
}
|
|
|
|
if id.len() > 64 {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"ID exceeds maximum length of 64: {id}"
|
|
)));
|
|
}
|
|
|
|
let first_char = id.chars().next();
|
|
if first_char.is_none_or(|c| !c.is_ascii_alphabetic()) {
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"ID must start with a letter: {id}"
|
|
)));
|
|
}
|
|
|
|
if !id
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
|
{
|
|
return Err(SchemaError::ValidationError(format!(
|
|
"ID contains invalid characters: {id}"
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_ui_element_depth() {
|
|
let element = UiElement::Container {
|
|
children: vec![UiElement::Container {
|
|
children: vec![UiElement::Text {
|
|
content: TextContent::Static("test".to_string()),
|
|
variant: TextVariant::default(),
|
|
allow_html: false,
|
|
}],
|
|
gap: 16,
|
|
padding: None,
|
|
}],
|
|
gap: 16,
|
|
padding: None,
|
|
};
|
|
|
|
assert_eq!(element.depth(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_id() {
|
|
assert!(validate_id("valid-id").is_ok());
|
|
assert!(validate_id("valid_id_123").is_ok());
|
|
assert!(validate_id("ValidId").is_ok());
|
|
|
|
assert!(validate_id("").is_err());
|
|
assert!(validate_id("123-start").is_err());
|
|
assert!(validate_id("invalid.id").is_err());
|
|
assert!(validate_id("a".repeat(65).as_str()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_column_data_type_default() {
|
|
let column: ColumnDef =
|
|
serde_json::from_str(r#"{"key": "name", "header": "Name"}"#).unwrap();
|
|
assert_eq!(column.data_type, ColumnDataType::Text);
|
|
assert!(column.sortable);
|
|
}
|
|
|
|
#[test]
|
|
fn test_data_source_endpoint_validation() {
|
|
let valid = DataSource::Endpoint {
|
|
method: HttpMethod::Get,
|
|
path: "/api/test".to_string(),
|
|
params: HashMap::new(),
|
|
poll_interval: 0,
|
|
transform: None,
|
|
};
|
|
assert!(valid.validate().is_ok());
|
|
|
|
let invalid = DataSource::Endpoint {
|
|
method: HttpMethod::Get,
|
|
path: "api/test".to_string(),
|
|
params: HashMap::new(),
|
|
poll_interval: 0,
|
|
transform: None,
|
|
};
|
|
assert!(invalid.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_text_content_from_string() {
|
|
let content: TextContent = "hello".into();
|
|
assert_eq!(content, TextContent::Static("hello".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expression_default() {
|
|
let expr = Expression::default();
|
|
assert_eq!(expr, Expression::Literal(serde_json::Value::Null));
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_definition_default() {
|
|
let action = ActionDefinition::default();
|
|
assert_eq!(action.method, HttpMethod::Get);
|
|
assert!(action.path.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_data_source_serialization() {
|
|
let source = DataSource::Static {
|
|
value: serde_json::json!({"key": "value"}),
|
|
};
|
|
let json = serde_json::to_string(&source).unwrap();
|
|
assert!(json.contains("static"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ui_page_referenced_data_sources() {
|
|
let page = UiPage {
|
|
id: "test".to_string(),
|
|
title: "Test".to_string(),
|
|
route: "/test".to_string(),
|
|
icon: None,
|
|
root_element: UiElement::DataTable {
|
|
columns: vec![],
|
|
data: "items".to_string(),
|
|
sortable: true,
|
|
filterable: false,
|
|
page_size: 0,
|
|
row_actions: vec![],
|
|
},
|
|
data_sources: HashMap::new(),
|
|
actions: HashMap::new(),
|
|
};
|
|
|
|
let refs = page.referenced_data_sources();
|
|
assert_eq!(refs, vec!["items"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_grid_validation() {
|
|
let page = UiPage {
|
|
id: "test".to_string(),
|
|
title: "Test".to_string(),
|
|
route: "/test".to_string(),
|
|
icon: None,
|
|
root_element: UiElement::Grid {
|
|
children: vec![],
|
|
columns: 13,
|
|
gap: 16,
|
|
},
|
|
data_sources: HashMap::new(),
|
|
actions: HashMap::new(),
|
|
};
|
|
|
|
assert!(page.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_heading_validation() {
|
|
let page = UiPage {
|
|
id: "test".to_string(),
|
|
title: "Test".to_string(),
|
|
route: "/test".to_string(),
|
|
icon: None,
|
|
root_element: UiElement::Heading {
|
|
level: 7,
|
|
content: TextContent::Static("Title".to_string()),
|
|
id: None,
|
|
},
|
|
data_sources: HashMap::new(),
|
|
actions: HashMap::new(),
|
|
};
|
|
|
|
assert!(page.validate().is_err());
|
|
}
|
|
|
|
// Expression JSON round-trip tests
|
|
|
|
/// A JSON string must deserialise as Path, not Literal.
|
|
#[test]
|
|
fn test_expression_string_deserialises_as_path() {
|
|
let expr: Expression = serde_json::from_str(r#""user.name""#).unwrap();
|
|
assert_eq!(expr, Expression::Path("user.name".to_string()));
|
|
}
|
|
|
|
/// A JSON number must deserialise as Literal, not Path.
|
|
#[test]
|
|
fn test_expression_number_deserialises_as_literal() {
|
|
let expr: Expression = serde_json::from_str("42").unwrap();
|
|
assert_eq!(expr, Expression::Literal(serde_json::json!(42)));
|
|
}
|
|
|
|
/// An Operation object is correctly deserialised.
|
|
#[test]
|
|
fn test_expression_operation_deserialises() {
|
|
let json = r#"{"left": "count", "op": "gt", "right": 0}"#;
|
|
let expr: Expression = serde_json::from_str(json).unwrap();
|
|
match expr {
|
|
Expression::Operation { left, op, right } => {
|
|
assert_eq!(*left, Expression::Path("count".to_string()));
|
|
assert_eq!(op, Operator::Gt);
|
|
assert_eq!(*right, Expression::Literal(serde_json::json!(0)));
|
|
},
|
|
other => panic!("expected Operation, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A Call object is correctly deserialised.
|
|
#[test]
|
|
fn test_expression_call_deserialises() {
|
|
let json = r#"{"function": "len", "args": ["items"]}"#;
|
|
let expr: Expression = serde_json::from_str(json).unwrap();
|
|
match expr {
|
|
Expression::Call { function, args } => {
|
|
assert_eq!(function, "len");
|
|
assert_eq!(args, vec![Expression::Path("items".to_string())]);
|
|
},
|
|
other => panic!("expected Call, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Path expressions survive a full JSON round-trip.
|
|
#[test]
|
|
fn test_expression_path_round_trip() {
|
|
let original = Expression::Path("a.b.c".to_string());
|
|
let json = serde_json::to_string(&original).unwrap();
|
|
let recovered: Expression = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(original, recovered);
|
|
}
|
|
|
|
// DataSource/ActionDefinition security validation tests
|
|
|
|
#[test]
|
|
fn test_endpoint_path_must_start_with_api() {
|
|
let bad = DataSource::Endpoint {
|
|
method: HttpMethod::Get,
|
|
path: "/not-api/something".to_string(),
|
|
params: HashMap::new(),
|
|
poll_interval: 0,
|
|
transform: None,
|
|
};
|
|
assert!(bad.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_endpoint_path_rejects_traversal() {
|
|
let bad = DataSource::Endpoint {
|
|
method: HttpMethod::Get,
|
|
path: "/api/v1/../admin".to_string(),
|
|
params: HashMap::new(),
|
|
poll_interval: 0,
|
|
transform: None,
|
|
};
|
|
assert!(bad.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_path_must_start_with_api() {
|
|
let bad = ActionDefinition {
|
|
method: HttpMethod::Post,
|
|
path: "/admin/reset".to_string(),
|
|
..ActionDefinition::default()
|
|
};
|
|
assert!(bad.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_path_rejects_traversal() {
|
|
let bad = ActionDefinition {
|
|
method: HttpMethod::Post,
|
|
path: "/api/v1/tags/../../auth/login".to_string(),
|
|
..ActionDefinition::default()
|
|
};
|
|
assert!(bad.validate().is_err());
|
|
}
|
|
|
|
// Link href safety tests
|
|
|
|
#[test]
|
|
fn test_is_safe_href_allows_relative() {
|
|
assert!(is_safe_href("/some/path"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_safe_href_allows_https() {
|
|
assert!(is_safe_href("https://example.com/page"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_safe_href_allows_http() {
|
|
assert!(is_safe_href("http://example.com/page"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_safe_href_rejects_javascript() {
|
|
assert!(!is_safe_href("javascript:alert(1)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_safe_href_rejects_data_uri() {
|
|
assert!(!is_safe_href("data:text/html,<script>alert(1)</script>"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_safe_href_rejects_vbscript() {
|
|
assert!(!is_safe_href("vbscript:msgbox(1)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_link_validation_rejects_unsafe_href() {
|
|
use std::collections::HashMap as HM;
|
|
let page = UiPage {
|
|
id: "p".to_string(),
|
|
title: "P".to_string(),
|
|
route: "/api/plugins/p/p".to_string(),
|
|
icon: None,
|
|
root_element: UiElement::Link {
|
|
text: "click".to_string(),
|
|
href: "javascript:alert(1)".to_string(),
|
|
external: false,
|
|
},
|
|
data_sources: HM::new(),
|
|
actions: HM::new(),
|
|
};
|
|
assert!(page.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_reserved_route_rejected() {
|
|
use std::collections::HashMap as HM;
|
|
let page = UiPage {
|
|
id: "search-page".to_string(),
|
|
title: "Search".to_string(),
|
|
route: "/search".to_string(),
|
|
icon: None,
|
|
root_element: UiElement::Container {
|
|
children: vec![],
|
|
gap: 0,
|
|
padding: None,
|
|
},
|
|
data_sources: HM::new(),
|
|
actions: HM::new(),
|
|
};
|
|
let err = page.validate().unwrap_err();
|
|
assert!(
|
|
matches!(err, SchemaError::ValidationError(_)),
|
|
"expected ValidationError, got {err:?}"
|
|
);
|
|
assert!(
|
|
format!("{err}").contains("/search"),
|
|
"error should mention the conflicting route"
|
|
);
|
|
}
|
|
|
|
// --- SpecialAction JSON round-trips ---
|
|
|
|
#[test]
|
|
fn test_special_action_refresh_roundtrip() {
|
|
let action = SpecialAction::Refresh;
|
|
let json = serde_json::to_value(&action).unwrap();
|
|
assert_eq!(json["action"], "refresh");
|
|
let back: SpecialAction = serde_json::from_value(json).unwrap();
|
|
assert_eq!(back, SpecialAction::Refresh);
|
|
}
|
|
|
|
#[test]
|
|
fn test_special_action_navigate_roundtrip() {
|
|
let action = SpecialAction::Navigate {
|
|
to: "/foo".to_string(),
|
|
};
|
|
let json = serde_json::to_value(&action).unwrap();
|
|
assert_eq!(json["action"], "navigate");
|
|
assert_eq!(json["to"], "/foo");
|
|
let back: SpecialAction = serde_json::from_value(json).unwrap();
|
|
assert_eq!(back, SpecialAction::Navigate {
|
|
to: "/foo".to_string(),
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_special_action_emit_roundtrip() {
|
|
let action = SpecialAction::Emit {
|
|
event: "my-event".to_string(),
|
|
payload: serde_json::json!({"key": "val"}),
|
|
};
|
|
let json = serde_json::to_value(&action).unwrap();
|
|
assert_eq!(json["action"], "emit");
|
|
assert_eq!(json["event"], "my-event");
|
|
let back: SpecialAction = serde_json::from_value(json).unwrap();
|
|
assert_eq!(back, action);
|
|
}
|
|
|
|
#[test]
|
|
fn test_special_action_update_state_roundtrip() {
|
|
let action = SpecialAction::UpdateState {
|
|
key: "my-key".to_string(),
|
|
value: Expression::Literal(serde_json::json!(42)),
|
|
};
|
|
let json = serde_json::to_value(&action).unwrap();
|
|
assert_eq!(json["action"], "update_state");
|
|
assert_eq!(json["key"], "my-key");
|
|
let back: SpecialAction = serde_json::from_value(json).unwrap();
|
|
assert_eq!(back, action);
|
|
}
|
|
|
|
#[test]
|
|
fn test_special_action_close_modal_roundtrip() {
|
|
let action = SpecialAction::CloseModal;
|
|
let json = serde_json::to_value(&action).unwrap();
|
|
assert_eq!(json["action"], "close_modal");
|
|
let back: SpecialAction = serde_json::from_value(json).unwrap();
|
|
assert_eq!(back, SpecialAction::CloseModal);
|
|
}
|
|
|
|
// --- ActionRef deserialization ordering ---
|
|
|
|
#[test]
|
|
fn test_action_ref_special_refresh_deserializes() {
|
|
let json = serde_json::json!({"action": "refresh"});
|
|
let action_ref: ActionRef = serde_json::from_value(json).unwrap();
|
|
assert!(matches!(
|
|
action_ref,
|
|
ActionRef::Special(SpecialAction::Refresh)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_ref_special_navigate_deserializes() {
|
|
let json = serde_json::json!({"action": "navigate", "to": "/foo"});
|
|
let action_ref: ActionRef = serde_json::from_value(json).unwrap();
|
|
assert!(matches!(
|
|
action_ref,
|
|
ActionRef::Special(SpecialAction::Navigate { to }) if to == "/foo"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_ref_name_still_works() {
|
|
let json = serde_json::json!("my-action");
|
|
let action_ref: ActionRef = serde_json::from_value(json).unwrap();
|
|
assert!(matches!(action_ref, ActionRef::Name(n) if n == "my-action"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_ref_special_takes_priority_over_inline() {
|
|
// An object with "action":"refresh" must be SpecialAction, not
|
|
// misinterpreted as ActionDefinition.
|
|
let json = serde_json::json!({"action": "refresh"});
|
|
let action_ref: ActionRef = serde_json::from_value(json).unwrap();
|
|
assert!(
|
|
matches!(action_ref, ActionRef::Special(_)),
|
|
"SpecialAction must be matched before ActionDefinition"
|
|
);
|
|
}
|
|
}
|