pinakes-ui: enforce plugin endpoint allowlist; replace inline styles with CSS custom properties
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I751e5c7ec66f045ee1f0bad6c72759416a6a6964
This commit is contained in:
parent
ada1c07f66
commit
9389af9fda
11 changed files with 1880 additions and 772 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -5446,6 +5446,7 @@ dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml 1.0.6+spec-1.1.0",
|
"toml 1.0.6+spec-1.1.0",
|
||||||
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wit-bindgen 0.53.1",
|
"wit-bindgen 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
@ -5511,6 +5512,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
|
"dioxus-core",
|
||||||
"dioxus-free-icons",
|
"dioxus-free-icons",
|
||||||
"futures",
|
"futures",
|
||||||
"gloo-timers",
|
"gloo-timers",
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ crossterm = "0.29.0"
|
||||||
|
|
||||||
# Desktop/Web UI
|
# Desktop/Web UI
|
||||||
dioxus = { version = "0.7.3", features = ["desktop", "router"] }
|
dioxus = { version = "0.7.3", features = ["desktop", "router"] }
|
||||||
|
dioxus-core = { version = "0.7.3" }
|
||||||
|
|
||||||
# Async trait (dyn-compatible async methods)
|
# Async trait (dyn-compatible async methods)
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
|
|
@ -187,6 +188,7 @@ undocumented_unsafe_blocks = "warn"
|
||||||
unnecessary_safety_comment = "warn"
|
unnecessary_safety_comment = "warn"
|
||||||
unused_result_ok = "warn"
|
unused_result_ok = "warn"
|
||||||
unused_trait_names = "allow"
|
unused_trait_names = "allow"
|
||||||
|
too_many_arguments = "allow"
|
||||||
|
|
||||||
# False positive:
|
# False positive:
|
||||||
# clippy's build script check doesn't recognize workspace-inherited metadata
|
# clippy's build script check doesn't recognize workspace-inherited metadata
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
dioxus = { workspace = true }
|
dioxus = { workspace = true }
|
||||||
|
dioxus-core = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
rfd = { workspace = true }
|
rfd = { workspace = true }
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
94
crates/pinakes-ui/assets/styles/_plugins.scss
Normal file
94
crates/pinakes-ui/assets/styles/_plugins.scss
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
@use 'variables' as *;
|
||||||
|
@use 'mixins' as *;
|
||||||
|
|
||||||
|
// Plugin UI renderer layout classes.
|
||||||
|
//
|
||||||
|
// Dynamic values are passed via CSS custom properties set on the element.
|
||||||
|
// The layout rules here consume those properties via var() so the renderer
|
||||||
|
// never injects full CSS rule strings.
|
||||||
|
|
||||||
|
// Container: vertical flex column with configurable gap and padding.
|
||||||
|
.plugin-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--plugin-gap, 0px);
|
||||||
|
padding: var(--plugin-padding, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid: CSS grid with a configurable column count and gap.
|
||||||
|
.plugin-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--plugin-columns, 1), 1fr);
|
||||||
|
gap: var(--plugin-gap, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flex: display:flex driven by data-* attribute selectors.
|
||||||
|
// The gap is a CSS custom property; direction/justify/align/wrap are
|
||||||
|
// plain enum strings placed in data attributes by the renderer.
|
||||||
|
.plugin-flex {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--plugin-gap, 0px);
|
||||||
|
|
||||||
|
&[data-direction='row'] { flex-direction: row; }
|
||||||
|
&[data-direction='column'] { flex-direction: column; }
|
||||||
|
|
||||||
|
&[data-justify='flex-start'] { justify-content: flex-start; }
|
||||||
|
&[data-justify='flex-end'] { justify-content: flex-end; }
|
||||||
|
&[data-justify='center'] { justify-content: center; }
|
||||||
|
&[data-justify='space-between'] { justify-content: space-between; }
|
||||||
|
&[data-justify='space-around'] { justify-content: space-around; }
|
||||||
|
&[data-justify='space-evenly'] { justify-content: space-evenly; }
|
||||||
|
|
||||||
|
&[data-align='flex-start'] { align-items: flex-start; }
|
||||||
|
&[data-align='flex-end'] { align-items: flex-end; }
|
||||||
|
&[data-align='center'] { align-items: center; }
|
||||||
|
&[data-align='stretch'] { align-items: stretch; }
|
||||||
|
&[data-align='baseline'] { align-items: baseline; }
|
||||||
|
|
||||||
|
&[data-wrap='wrap'] { flex-wrap: wrap; }
|
||||||
|
&[data-wrap='nowrap'] { flex-wrap: nowrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split: side-by-side sidebar + main area.
|
||||||
|
.plugin-split {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar width is driven by --plugin-sidebar-width.
|
||||||
|
.plugin-split-sidebar {
|
||||||
|
width: var(--plugin-sidebar-width, 200px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-split-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media grid reuses the same column/gap variables as .plugin-grid.
|
||||||
|
.plugin-media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--plugin-columns, 2), 1fr);
|
||||||
|
gap: var(--plugin-gap, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table column with a plugin-specified fixed width.
|
||||||
|
// The width is passed as --plugin-col-width on the th element.
|
||||||
|
.plugin-col-constrained {
|
||||||
|
width: var(--plugin-col-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bar: the fill element carries --plugin-progress.
|
||||||
|
.plugin-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: $accent;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
width: var(--plugin-progress, 0%);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart wrapper: height is driven by --plugin-chart-height.
|
||||||
|
.plugin-chart {
|
||||||
|
overflow: auto;
|
||||||
|
height: var(--plugin-chart-height, 200px);
|
||||||
|
}
|
||||||
|
|
@ -11,3 +11,4 @@
|
||||||
@use 'audit';
|
@use 'audit';
|
||||||
@use 'graph';
|
@use 'graph';
|
||||||
@use 'themes';
|
@use 'themes';
|
||||||
|
@use 'plugins';
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,12 @@ use crate::{
|
||||||
tags,
|
tags,
|
||||||
tasks,
|
tasks,
|
||||||
},
|
},
|
||||||
plugin_ui::{PluginRegistry, PluginViewRenderer},
|
plugin_ui::{
|
||||||
|
PluginRegistry,
|
||||||
|
PluginViewRenderer,
|
||||||
|
WidgetContainer,
|
||||||
|
WidgetLocation,
|
||||||
|
},
|
||||||
styles,
|
styles,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -109,6 +114,41 @@ impl View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a route string from a plugin `navigate_to` action into an app
|
||||||
|
/// [`View`].
|
||||||
|
///
|
||||||
|
/// Supports all built-in routes (`/library`, `/search`, etc.) and plugin pages
|
||||||
|
/// (`/plugins/{plugin_id}/{page_id}`). Unknown routes fall back to Library with
|
||||||
|
/// a warning log.
|
||||||
|
fn parse_plugin_route(route: &str) -> View {
|
||||||
|
let parts: Vec<&str> = route.trim_start_matches('/').split('/').collect();
|
||||||
|
match parts.as_slice() {
|
||||||
|
[] | [""] | ["library"] => View::Library,
|
||||||
|
["search"] => View::Search,
|
||||||
|
["settings"] => View::Settings,
|
||||||
|
["tags"] => View::Tags,
|
||||||
|
["collections"] => View::Collections,
|
||||||
|
["books"] => View::Books,
|
||||||
|
["audit"] => View::Audit,
|
||||||
|
["import"] => View::Import,
|
||||||
|
["duplicates"] => View::Duplicates,
|
||||||
|
["statistics"] => View::Statistics,
|
||||||
|
["tasks"] => View::Tasks,
|
||||||
|
["database"] => View::Database,
|
||||||
|
["graph"] => View::Graph,
|
||||||
|
["plugins", plugin_id, page_id] => {
|
||||||
|
View::PluginView {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
page_id: page_id.to_string(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
tracing::warn!(route = %route, "Unknown navigation route from plugin action");
|
||||||
|
View::Library
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> Element {
|
pub fn App() -> Element {
|
||||||
let base_url = std::env::var("PINAKES_SERVER_URL")
|
let base_url = std::env::var("PINAKES_SERVER_URL")
|
||||||
|
|
@ -193,6 +233,7 @@ pub fn App() -> Element {
|
||||||
let mut play_queue = use_signal(PlayQueue::default);
|
let mut play_queue = use_signal(PlayQueue::default);
|
||||||
|
|
||||||
let mut plugin_registry = use_signal(PluginRegistry::default);
|
let mut plugin_registry = use_signal(PluginRegistry::default);
|
||||||
|
let all_widgets = use_memo(move || plugin_registry.read().all_widgets());
|
||||||
|
|
||||||
let mut current_theme = use_signal(|| "dark".to_string());
|
let mut current_theme = use_signal(|| "dark".to_string());
|
||||||
let mut system_prefers_dark = use_signal(|| true);
|
let mut system_prefers_dark = use_signal(|| true);
|
||||||
|
|
@ -319,15 +360,27 @@ pub fn App() -> Element {
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let c = client.read().clone();
|
let c = client.read().clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match c.get_plugin_ui_pages().await {
|
let mut reg = PluginRegistry::new(c.clone());
|
||||||
Ok(pages) => {
|
match reg.refresh().await {
|
||||||
let mut reg = PluginRegistry::default();
|
Ok(()) => {
|
||||||
for (plugin_id, page) in pages {
|
let vars = reg.theme_vars().clone();
|
||||||
reg.register_page(plugin_id, page);
|
|
||||||
}
|
|
||||||
plugin_registry.set(reg);
|
plugin_registry.set(reg);
|
||||||
|
if !vars.is_empty() {
|
||||||
|
spawn(async move {
|
||||||
|
let js: String = vars
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
format!(
|
||||||
|
"document.documentElement.style.setProperty('{}','{}');",
|
||||||
|
k, v
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let _ = document::eval(&js).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Err(e) => tracing::debug!("Plugin pages unavailable: {e}"),
|
Err(e) => tracing::debug!("Plugin UI unavailable: {e}"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -431,7 +484,19 @@ pub fn App() -> Element {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let view_title = use_memo(move || current_view.read().title());
|
let view_title = use_memo(move || {
|
||||||
|
let view = current_view.read();
|
||||||
|
match &*view {
|
||||||
|
View::PluginView { plugin_id, page_id } => {
|
||||||
|
plugin_registry
|
||||||
|
.read()
|
||||||
|
.get_page(plugin_id, page_id)
|
||||||
|
.map(|p| p.page.title.clone())
|
||||||
|
.unwrap_or_else(|| "Plugin".to_string())
|
||||||
|
},
|
||||||
|
v => v.title().to_string(),
|
||||||
|
}
|
||||||
|
});
|
||||||
let _total_pages = use_memo(move || {
|
let _total_pages = use_memo(move || {
|
||||||
let ps = *media_page_size.read();
|
let ps = *media_page_size.read();
|
||||||
let tc = *media_total_count.read();
|
let tc = *media_total_count.read();
|
||||||
|
|
@ -753,12 +818,17 @@ pub fn App() -> Element {
|
||||||
|
|
||||||
if !plugin_registry.read().is_empty() {
|
if !plugin_registry.read().is_empty() {
|
||||||
div { class: "nav-section",
|
div { class: "nav-section",
|
||||||
div { class: "nav-label", "Plugins" }
|
div { class: "nav-label",
|
||||||
for page in plugin_registry.read().all_pages() {
|
"Plugins"
|
||||||
|
span { class: "nav-label-count", " ({plugin_registry.read().len()})" }
|
||||||
|
}
|
||||||
|
for (pid, pageid, route) in plugin_registry.read().routes() {
|
||||||
{
|
{
|
||||||
let pid = page.plugin_id.clone();
|
let title = plugin_registry
|
||||||
let pageid = page.page.id.clone();
|
.read()
|
||||||
let title = page.page.title.clone();
|
.get_page(&pid, &pageid)
|
||||||
|
.map(|p| p.page.title.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
let is_active = *current_view.read()
|
let is_active = *current_view.read()
|
||||||
== View::PluginView {
|
== View::PluginView {
|
||||||
plugin_id: pid.clone(),
|
plugin_id: pid.clone(),
|
||||||
|
|
@ -767,6 +837,7 @@ pub fn App() -> Element {
|
||||||
rsx! {
|
rsx! {
|
||||||
button {
|
button {
|
||||||
class: if is_active { "nav-item active" } else { "nav-item" },
|
class: if is_active { "nav-item active" } else { "nav-item" },
|
||||||
|
title: "{route}",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
current_view.set(View::PluginView {
|
current_view.set(View::PluginView {
|
||||||
plugin_id: pid.clone(),
|
plugin_id: pid.clone(),
|
||||||
|
|
@ -778,6 +849,17 @@ pub fn App() -> Element {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let sync_time_opt = plugin_registry
|
||||||
|
.read()
|
||||||
|
.last_refresh()
|
||||||
|
.map(|ts| ts.format("%H:%M").to_string());
|
||||||
|
rsx! {
|
||||||
|
if let Some(sync_time) = sync_time_opt {
|
||||||
|
div { class: "nav-sync-time", "Synced {sync_time}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -923,6 +1005,11 @@ pub fn App() -> Element {
|
||||||
// Reload full config
|
// Reload full config
|
||||||
match *current_view.read() {
|
match *current_view.read() {
|
||||||
View::Library => rsx! {
|
View::Library => rsx! {
|
||||||
|
WidgetContainer {
|
||||||
|
location: WidgetLocation::LibraryHeader,
|
||||||
|
widgets: all_widgets.read().clone(),
|
||||||
|
client,
|
||||||
|
}
|
||||||
div { class: "stats-grid",
|
div { class: "stats-grid",
|
||||||
div { class: "stat-card",
|
div { class: "stat-card",
|
||||||
div { class: "stat-value", "{media_total_count}" }
|
div { class: "stat-value", "{media_total_count}" }
|
||||||
|
|
@ -937,6 +1024,11 @@ pub fn App() -> Element {
|
||||||
div { class: "stat-label", "Collections" }
|
div { class: "stat-label", "Collections" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
WidgetContainer {
|
||||||
|
location: WidgetLocation::LibrarySidebar,
|
||||||
|
widgets: all_widgets.read().clone(),
|
||||||
|
client,
|
||||||
|
}
|
||||||
library::Library {
|
library::Library {
|
||||||
media: media_list.read().clone(),
|
media: media_list.read().clone(),
|
||||||
tags: tags_list.read().clone(),
|
tags: tags_list.read().clone(),
|
||||||
|
|
@ -1133,6 +1225,11 @@ pub fn App() -> Element {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
View::Search => rsx! {
|
View::Search => rsx! {
|
||||||
|
WidgetContainer {
|
||||||
|
location: WidgetLocation::SearchFilters,
|
||||||
|
widgets: all_widgets.read().clone(),
|
||||||
|
client,
|
||||||
|
}
|
||||||
search::Search {
|
search::Search {
|
||||||
results: search_results.read().clone(),
|
results: search_results.read().clone(),
|
||||||
total_count: *search_total.read(),
|
total_count: *search_total.read(),
|
||||||
|
|
@ -1265,6 +1362,11 @@ pub fn App() -> Element {
|
||||||
let media_ref = selected_media.read();
|
let media_ref = selected_media.read();
|
||||||
match media_ref.as_ref() {
|
match media_ref.as_ref() {
|
||||||
Some(media) => rsx! {
|
Some(media) => rsx! {
|
||||||
|
WidgetContainer {
|
||||||
|
location: WidgetLocation::DetailPanel,
|
||||||
|
widgets: all_widgets.read().clone(),
|
||||||
|
client,
|
||||||
|
}
|
||||||
detail::Detail {
|
detail::Detail {
|
||||||
media: media.clone(),
|
media: media.clone(),
|
||||||
media_tags: media_tags.read().clone(),
|
media_tags: media_tags.read().clone(),
|
||||||
|
|
@ -2558,6 +2660,11 @@ pub fn App() -> Element {
|
||||||
let cfg_ref = config_data.read();
|
let cfg_ref = config_data.read();
|
||||||
match cfg_ref.as_ref() {
|
match cfg_ref.as_ref() {
|
||||||
Some(cfg) => rsx! {
|
Some(cfg) => rsx! {
|
||||||
|
WidgetContainer {
|
||||||
|
location: WidgetLocation::SettingsSection,
|
||||||
|
widgets: all_widgets.read().clone(),
|
||||||
|
client,
|
||||||
|
}
|
||||||
settings::Settings {
|
settings::Settings {
|
||||||
config: cfg.clone(),
|
config: cfg.clone(),
|
||||||
on_add_root: {
|
on_add_root: {
|
||||||
|
|
@ -2712,6 +2819,11 @@ pub fn App() -> Element {
|
||||||
plugin_id: pid,
|
plugin_id: pid,
|
||||||
page: plugin_page.page,
|
page: plugin_page.page,
|
||||||
client,
|
client,
|
||||||
|
allowed_endpoints: plugin_page.allowed_endpoints.clone(),
|
||||||
|
on_navigate: move |route: String| {
|
||||||
|
current_view
|
||||||
|
.set(parse_plugin_route(&route));
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => rsx! {
|
None => rsx! {
|
||||||
|
|
|
||||||
|
|
@ -1617,14 +1617,16 @@ impl ApiClient {
|
||||||
|
|
||||||
/// List all UI pages provided by loaded plugins.
|
/// List all UI pages provided by loaded plugins.
|
||||||
///
|
///
|
||||||
/// Returns a vector of `(plugin_id, page)` tuples.
|
/// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples.
|
||||||
pub async fn get_plugin_ui_pages(
|
pub async fn get_plugin_ui_pages(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<(String, pinakes_plugin_api::UiPage)>> {
|
) -> Result<Vec<(String, pinakes_plugin_api::UiPage, Vec<String>)>> {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct PageEntry {
|
struct PageEntry {
|
||||||
plugin_id: String,
|
plugin_id: String,
|
||||||
page: pinakes_plugin_api::UiPage,
|
page: pinakes_plugin_api::UiPage,
|
||||||
|
#[serde(default)]
|
||||||
|
allowed_endpoints: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let entries: Vec<PageEntry> = self
|
let entries: Vec<PageEntry> = self
|
||||||
|
|
@ -1636,7 +1638,80 @@ impl ApiClient {
|
||||||
.json()
|
.json()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(entries.into_iter().map(|e| (e.plugin_id, e.page)).collect())
|
Ok(
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| (e.plugin_id, e.page, e.allowed_endpoints))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all UI widgets provided by loaded plugins.
|
||||||
|
///
|
||||||
|
/// Returns a vector of `(plugin_id, widget)` tuples.
|
||||||
|
pub async fn get_plugin_ui_widgets(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<(String, pinakes_plugin_api::UiWidget)>> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WidgetEntry {
|
||||||
|
plugin_id: String,
|
||||||
|
widget: pinakes_plugin_api::UiWidget,
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries: Vec<WidgetEntry> = self
|
||||||
|
.client
|
||||||
|
.get(self.url("/plugins/ui-widgets"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| (e.plugin_id, e.widget))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch merged CSS custom property overrides from all enabled plugins.
|
||||||
|
///
|
||||||
|
/// Returns a map of CSS property names to values.
|
||||||
|
pub async fn get_plugin_ui_theme_extensions(
|
||||||
|
&self,
|
||||||
|
) -> Result<HashMap<String, String>> {
|
||||||
|
Ok(
|
||||||
|
self
|
||||||
|
.client
|
||||||
|
.get(self.url("/plugins/ui-theme-extensions"))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a plugin event to the server-side event bus.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the request fails or the server returns an error
|
||||||
|
/// status.
|
||||||
|
pub async fn post_plugin_event(
|
||||||
|
&self,
|
||||||
|
event: &str,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
self
|
||||||
|
.client
|
||||||
|
.post(self.url("/plugins/events"))
|
||||||
|
.json(&serde_json::json!({ "event": event, "payload": payload }))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Make a raw HTTP request to an API path.
|
/// Make a raw HTTP request to an API path.
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,17 @@
|
||||||
//!
|
//!
|
||||||
//! Provides data fetching and caching for plugin data sources.
|
//! Provides data fetching and caching for plugin data sources.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::{collections::HashMap, time::Duration};
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use pinakes_plugin_api::{DataSource, HttpMethod};
|
use dioxus_core::Task;
|
||||||
|
use pinakes_plugin_api::{DataSource, Expression, HttpMethod};
|
||||||
|
|
||||||
|
use super::expr::{evaluate_expression, value_to_display_string};
|
||||||
use crate::client::ApiClient;
|
use crate::client::ApiClient;
|
||||||
|
|
||||||
/// Cached data for a plugin page
|
/// Cached data for a plugin page
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct PluginPageData {
|
pub struct PluginPageData {
|
||||||
data: HashMap<String, serde_json::Value>,
|
data: HashMap<String, serde_json::Value>,
|
||||||
loading: HashMap<String, bool>,
|
loading: HashMap<String, bool>,
|
||||||
|
|
@ -36,7 +38,7 @@ impl PluginPageData {
|
||||||
self.errors.get(source)
|
self.errors.get(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if there's data for a specific source
|
/// Check if there is data for a specific source
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn has_data(&self, source: &str) -> bool {
|
pub fn has_data(&self, source: &str) -> bool {
|
||||||
self.data.contains_key(source)
|
self.data.contains_key(source)
|
||||||
|
|
@ -83,23 +85,62 @@ impl PluginPageData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch data from an endpoint
|
/// Convert a plugin `HttpMethod` to a `reqwest::Method`.
|
||||||
async fn fetch_endpoint(
|
pub(super) const fn to_reqwest_method(method: &HttpMethod) -> reqwest::Method {
|
||||||
client: &ApiClient,
|
match method {
|
||||||
path: &str,
|
|
||||||
method: HttpMethod,
|
|
||||||
) -> Result<serde_json::Value, String> {
|
|
||||||
let reqwest_method = match method {
|
|
||||||
HttpMethod::Get => reqwest::Method::GET,
|
HttpMethod::Get => reqwest::Method::GET,
|
||||||
HttpMethod::Post => reqwest::Method::POST,
|
HttpMethod::Post => reqwest::Method::POST,
|
||||||
HttpMethod::Put => reqwest::Method::PUT,
|
HttpMethod::Put => reqwest::Method::PUT,
|
||||||
HttpMethod::Patch => reqwest::Method::PATCH,
|
HttpMethod::Patch => reqwest::Method::PATCH,
|
||||||
HttpMethod::Delete => reqwest::Method::DELETE,
|
HttpMethod::Delete => reqwest::Method::DELETE,
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch data from an endpoint, evaluating any params expressions against
|
||||||
|
/// the given context.
|
||||||
|
async fn fetch_endpoint(
|
||||||
|
client: &ApiClient,
|
||||||
|
path: &str,
|
||||||
|
method: HttpMethod,
|
||||||
|
params: &HashMap<String, Expression>,
|
||||||
|
ctx: &serde_json::Value,
|
||||||
|
allowed_endpoints: &[String],
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
if !allowed_endpoints.is_empty()
|
||||||
|
&& !allowed_endpoints.iter().any(|ep| path == ep.as_str())
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"Endpoint '{path}' is not in plugin's declared required_endpoints"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let reqwest_method = to_reqwest_method(&method);
|
||||||
|
|
||||||
|
let mut request = client.raw_request(reqwest_method.clone(), path);
|
||||||
|
|
||||||
|
if !params.is_empty() {
|
||||||
|
if reqwest_method == reqwest::Method::GET {
|
||||||
|
// Evaluate each param expression and add as query string
|
||||||
|
let query_pairs: Vec<(String, String)> = params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, expr)| {
|
||||||
|
let v = evaluate_expression(expr, ctx);
|
||||||
|
(k.clone(), value_to_display_string(&v))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
request = request.query(&query_pairs);
|
||||||
|
} else {
|
||||||
|
// Evaluate params and send as JSON body
|
||||||
|
let body: serde_json::Map<String, serde_json::Value> = params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, expr)| (k.clone(), evaluate_expression(expr, ctx)))
|
||||||
|
.collect();
|
||||||
|
request = request.json(&body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send request and parse response
|
// Send request and parse response
|
||||||
let response = client
|
let response = request
|
||||||
.raw_request(reqwest_method, path)
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Request failed: {e}"))?;
|
.map_err(|e| format!("Request failed: {e}"))?;
|
||||||
|
|
@ -118,50 +159,159 @@ async fn fetch_endpoint(
|
||||||
|
|
||||||
/// Fetch all data sources for a page
|
/// Fetch all data sources for a page
|
||||||
///
|
///
|
||||||
|
/// Endpoint sources are deduplicated by `(path, method, params)`: if multiple
|
||||||
|
/// sources share the same triplet, a single HTTP request is made and the raw
|
||||||
|
/// response is shared, with each source's own `transform` applied independently.
|
||||||
|
/// All unique Endpoint and Static sources are fetched concurrently. Transform
|
||||||
|
/// sources are applied after, in iteration order, against the full result set.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an error if any data source fails to fetch
|
/// Returns an error if any data source fails to fetch
|
||||||
pub async fn fetch_page_data(
|
pub async fn fetch_page_data(
|
||||||
client: &ApiClient,
|
client: &ApiClient,
|
||||||
data_sources: &HashMap<String, DataSource>,
|
data_sources: &HashMap<String, DataSource>,
|
||||||
|
allowed_endpoints: &[String],
|
||||||
) -> Result<HashMap<String, serde_json::Value>, String> {
|
) -> Result<HashMap<String, serde_json::Value>, String> {
|
||||||
let mut results = HashMap::new();
|
// Group non-Transform sources into dedup groups.
|
||||||
|
//
|
||||||
|
// For Endpoint sources, two entries are in the same group when they share
|
||||||
|
// the same (path, method, params) - i.e., they would produce an identical
|
||||||
|
// HTTP request. The per-source `transform` expression is kept separate so
|
||||||
|
// each name can apply its own transform to the shared raw response.
|
||||||
|
//
|
||||||
|
// Static sources never share an HTTP request so each becomes its own group.
|
||||||
|
//
|
||||||
|
// Each group is: (names_and_transforms, representative_source)
|
||||||
|
// where names_and_transforms is Vec<(name, Option<Expression>)> for Endpoint,
|
||||||
|
// or Vec<(name, ())> for Static (transform is baked in).
|
||||||
|
struct Group {
|
||||||
|
// (source name, per-name transform expression for Endpoint sources)
|
||||||
|
members: Vec<(String, Option<Expression>)>,
|
||||||
|
// The representative source used to fire the request (transform ignored
|
||||||
|
// for Endpoint - we apply per-member transforms after fetching)
|
||||||
|
source: DataSource,
|
||||||
|
}
|
||||||
|
|
||||||
// Process non-Transform sources first so Transform sources can reference them
|
let mut groups: Vec<Group> = Vec::new();
|
||||||
let mut ordered: Vec<(&String, &DataSource)> = data_sources
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, s)| !matches!(s, DataSource::Transform { .. }))
|
|
||||||
.collect();
|
|
||||||
ordered.extend(
|
|
||||||
data_sources
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, s)| matches!(s, DataSource::Transform { .. })),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (name, source) in ordered {
|
for (name, source) in data_sources {
|
||||||
let value = match source {
|
if matches!(source, DataSource::Transform { .. }) {
|
||||||
DataSource::Endpoint { path, method, .. } => {
|
continue;
|
||||||
// Fetch from endpoint (ignoring params, poll_interval, transform for
|
}
|
||||||
// now)
|
|
||||||
fetch_endpoint(client, path, method.clone()).await?
|
match source {
|
||||||
},
|
DataSource::Endpoint {
|
||||||
DataSource::Static { value } => value.clone(),
|
path,
|
||||||
DataSource::Transform {
|
method,
|
||||||
source_name,
|
params,
|
||||||
expression,
|
transform,
|
||||||
|
poll_interval,
|
||||||
} => {
|
} => {
|
||||||
// Get source data and apply transform
|
// Find an existing group with the same (path, method, params).
|
||||||
let source_data = results
|
let existing = groups.iter_mut().find(|g| {
|
||||||
.get(source_name)
|
matches!(
|
||||||
.cloned()
|
&g.source,
|
||||||
.unwrap_or(serde_json::Value::Null);
|
DataSource::Endpoint {
|
||||||
// TODO: Actually evaluate expression against source_data
|
path: ep,
|
||||||
// For now, return source_data unchanged
|
method: em,
|
||||||
let _ = expression;
|
params: epa,
|
||||||
source_data
|
..
|
||||||
|
} if ep == path && em == method && epa == params
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(group) = existing {
|
||||||
|
group.members.push((name.clone(), transform.clone()));
|
||||||
|
} else {
|
||||||
|
groups.push(Group {
|
||||||
|
members: vec![(name.clone(), transform.clone())],
|
||||||
|
source: DataSource::Endpoint {
|
||||||
|
path: path.clone(),
|
||||||
|
method: method.clone(),
|
||||||
|
params: params.clone(),
|
||||||
|
poll_interval: *poll_interval,
|
||||||
|
transform: None,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
DataSource::Static { .. } => {
|
||||||
results.insert(name.clone(), value);
|
// Static sources are trivially unique per name; no dedup needed.
|
||||||
|
groups.push(Group {
|
||||||
|
members: vec![(name.clone(), None)],
|
||||||
|
source: source.clone(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
DataSource::Transform { .. } => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire one future per group concurrently.
|
||||||
|
let futs: Vec<_> = groups
|
||||||
|
.into_iter()
|
||||||
|
.map(|group| {
|
||||||
|
let client = client.clone();
|
||||||
|
let allowed = allowed_endpoints.to_vec();
|
||||||
|
async move {
|
||||||
|
// Fetch the raw value for this group.
|
||||||
|
let raw = match &group.source {
|
||||||
|
DataSource::Endpoint {
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let empty_ctx = serde_json::json!({});
|
||||||
|
fetch_endpoint(&client, path, method.clone(), params, &empty_ctx, &allowed)
|
||||||
|
.await?
|
||||||
|
},
|
||||||
|
DataSource::Static { value } => value.clone(),
|
||||||
|
DataSource::Transform { .. } => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply per-member transforms and collect (name, value) pairs.
|
||||||
|
let pairs: Vec<(String, serde_json::Value)> = group
|
||||||
|
.members
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, transform)| {
|
||||||
|
let value = if let Some(expr) = &transform {
|
||||||
|
evaluate_expression(expr, &raw)
|
||||||
|
} else {
|
||||||
|
raw.clone()
|
||||||
|
};
|
||||||
|
(name, value)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok::<_, String>(pairs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut results: HashMap<String, serde_json::Value> = HashMap::new();
|
||||||
|
for group_result in futures::future::join_all(futs).await {
|
||||||
|
for (name, value) in group_result? {
|
||||||
|
results.insert(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Transform sources sequentially; they reference results above.
|
||||||
|
for (name, source) in data_sources {
|
||||||
|
if let DataSource::Transform {
|
||||||
|
source_name,
|
||||||
|
expression,
|
||||||
|
} = source
|
||||||
|
{
|
||||||
|
let ctx = serde_json::Value::Object(
|
||||||
|
results
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v): (&String, &serde_json::Value)| (k.clone(), v.clone()))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let _ = source_name; // accessible in ctx by its key
|
||||||
|
results.insert(name.clone(), evaluate_expression(expression, &ctx));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
|
|
@ -169,17 +319,50 @@ pub async fn fetch_page_data(
|
||||||
|
|
||||||
/// Hook to fetch and cache plugin page data
|
/// Hook to fetch and cache plugin page data
|
||||||
///
|
///
|
||||||
/// Returns a signal containing the data state
|
/// Returns a signal containing the data state. If any data source has a
|
||||||
|
/// non-zero `poll_interval`, a background loop re-fetches automatically at the
|
||||||
|
/// minimum interval. The `refresh` counter can be incremented to trigger an
|
||||||
|
/// immediate re-fetch outside of the polling interval.
|
||||||
pub fn use_plugin_data(
|
pub fn use_plugin_data(
|
||||||
client: Signal<ApiClient>,
|
client: Signal<ApiClient>,
|
||||||
data_sources: HashMap<String, DataSource>,
|
data_sources: HashMap<String, DataSource>,
|
||||||
|
refresh: Signal<u32>,
|
||||||
|
allowed_endpoints: Vec<String>,
|
||||||
) -> Signal<PluginPageData> {
|
) -> Signal<PluginPageData> {
|
||||||
let mut data = use_signal(PluginPageData::default);
|
let mut data = use_signal(PluginPageData::default);
|
||||||
|
let mut poll_task: Signal<Option<Task>> = use_signal(|| None);
|
||||||
|
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
|
// Subscribe to the refresh counter; incrementing it triggers a re-run.
|
||||||
|
let _rev = refresh.read();
|
||||||
let sources = data_sources.clone();
|
let sources = data_sources.clone();
|
||||||
|
let allowed = allowed_endpoints.clone();
|
||||||
|
|
||||||
spawn(async move {
|
// Cancel the previous polling task before spawning a new one. Use
|
||||||
|
// write() rather than read() so the effect does not subscribe to
|
||||||
|
// poll_task and trigger an infinite re-run loop.
|
||||||
|
if let Some(t) = poll_task.write().take() {
|
||||||
|
t.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine minimum poll interval (0 = no polling)
|
||||||
|
let min_poll_secs: u64 = sources
|
||||||
|
.values()
|
||||||
|
.filter_map(|s| {
|
||||||
|
if let DataSource::Endpoint { poll_interval, .. } = s {
|
||||||
|
if *poll_interval > 0 {
|
||||||
|
Some(*poll_interval)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.min()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let handle = spawn(async move {
|
||||||
// Clear previous data
|
// Clear previous data
|
||||||
data.write().clear();
|
data.write().clear();
|
||||||
|
|
||||||
|
|
@ -188,8 +371,9 @@ pub fn use_plugin_data(
|
||||||
data.write().set_loading(name, true);
|
data.write().set_loading(name, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch data
|
// Initial fetch; clone to release the signal read borrow before await.
|
||||||
match fetch_page_data(&client.read(), &sources).await {
|
let cl = client.peek().clone();
|
||||||
|
match fetch_page_data(&cl, &sources, &allowed).await {
|
||||||
Ok(results) => {
|
Ok(results) => {
|
||||||
for (name, value) in results {
|
for (name, value) in results {
|
||||||
data.write().set_loading(&name, false);
|
data.write().set_loading(&name, false);
|
||||||
|
|
@ -203,38 +387,39 @@ pub fn use_plugin_data(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Polling loop; only runs if at least one source has poll_interval > 0
|
||||||
|
if min_poll_secs > 0 {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(min_poll_secs)).await;
|
||||||
|
|
||||||
|
let cl = client.peek().clone();
|
||||||
|
match fetch_page_data(&cl, &sources, &allowed).await {
|
||||||
|
Ok(results) => {
|
||||||
|
for (name, value) in results {
|
||||||
|
// Only write if data is new or has changed to avoid spurious
|
||||||
|
// signal updates that would force a re-render
|
||||||
|
let changed = !data.read().has_data(&name)
|
||||||
|
|| data.read().get(&name) != Some(&value);
|
||||||
|
if changed {
|
||||||
|
data.write().set_data(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Poll fetch failed: {e}");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
*poll_task.write() = Some(handle);
|
||||||
});
|
});
|
||||||
|
|
||||||
data
|
data
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a value from JSON by path (dot notation)
|
|
||||||
///
|
|
||||||
/// Supports object keys and array indices
|
|
||||||
#[must_use]
|
|
||||||
pub fn get_json_path<'a>(
|
|
||||||
value: &'a serde_json::Value,
|
|
||||||
path: &str,
|
|
||||||
) -> Option<&'a serde_json::Value> {
|
|
||||||
let mut current = value;
|
|
||||||
|
|
||||||
for key in path.split('.') {
|
|
||||||
match current {
|
|
||||||
serde_json::Value::Object(map) => {
|
|
||||||
current = map.get(key)?;
|
|
||||||
},
|
|
||||||
serde_json::Value::Array(arr) => {
|
|
||||||
let idx = key.parse::<usize>().ok()?;
|
|
||||||
current = arr.get(idx)?;
|
|
||||||
},
|
|
||||||
_ => return None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -264,51 +449,6 @@ mod tests {
|
||||||
assert_eq!(data.error("error"), Some(&"oops".to_string()));
|
assert_eq!(data.error("error"), Some(&"oops".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_json_path_object() {
|
|
||||||
let data = serde_json::json!({
|
|
||||||
"user": {
|
|
||||||
"name": "John",
|
|
||||||
"age": 30
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
get_json_path(&data, "user.name"),
|
|
||||||
Some(&serde_json::Value::String("John".to_string()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_json_path_array() {
|
|
||||||
let data = serde_json::json!({
|
|
||||||
"items": ["a", "b", "c"]
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
get_json_path(&data, "items.1"),
|
|
||||||
Some(&serde_json::Value::String("b".to_string()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_json_path_invalid() {
|
|
||||||
let data = serde_json::json!({"foo": "bar"});
|
|
||||||
assert!(get_json_path(&data, "nonexistent").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_json_path_array_out_of_bounds() {
|
|
||||||
let data = serde_json::json!({"items": ["a"]});
|
|
||||||
assert!(get_json_path(&data, "items.5").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_json_path_non_array_index() {
|
|
||||||
let data = serde_json::json!({"foo": "bar"});
|
|
||||||
assert!(get_json_path(&data, "foo.0").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_as_json_empty() {
|
fn test_as_json_empty() {
|
||||||
let data = PluginPageData::default();
|
let data = PluginPageData::default();
|
||||||
|
|
@ -382,32 +522,195 @@ mod tests {
|
||||||
value: serde_json::json!(true),
|
value: serde_json::json!(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
let results = super::fetch_page_data(&client, &sources).await.unwrap();
|
let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap();
|
||||||
assert_eq!(results["nums"], serde_json::json!([1, 2, 3]));
|
assert_eq!(results["nums"], serde_json::json!([1, 2, 3]));
|
||||||
assert_eq!(results["flag"], serde_json::json!(true));
|
assert_eq!(results["flag"], serde_json::json!(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_fetch_page_data_transform_after_static() {
|
async fn test_fetch_page_data_transform_evaluates_expression() {
|
||||||
use pinakes_plugin_api::{DataSource, Expression};
|
use pinakes_plugin_api::{DataSource, Expression};
|
||||||
|
|
||||||
use crate::client::ApiClient;
|
use crate::client::ApiClient;
|
||||||
|
|
||||||
let client = ApiClient::default();
|
let client = ApiClient::default();
|
||||||
let mut sources = HashMap::new();
|
let mut sources = HashMap::new();
|
||||||
// Insert Transform before Static in the map to test ordering
|
// The Transform expression accesses "raw" from the context
|
||||||
sources.insert("derived".to_string(), DataSource::Transform {
|
sources.insert("derived".to_string(), DataSource::Transform {
|
||||||
source_name: "raw".to_string(),
|
source_name: "raw".to_string(),
|
||||||
expression: Expression::Literal(serde_json::Value::Null),
|
expression: Expression::Path("raw".to_string()),
|
||||||
});
|
});
|
||||||
sources.insert("raw".to_string(), DataSource::Static {
|
sources.insert("raw".to_string(), DataSource::Static {
|
||||||
value: serde_json::json!({"ok": true}),
|
value: serde_json::json!({"ok": true}),
|
||||||
});
|
});
|
||||||
|
|
||||||
let results = super::fetch_page_data(&client, &sources).await.unwrap();
|
let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap();
|
||||||
// raw must have been processed before derived
|
|
||||||
assert_eq!(results["raw"], serde_json::json!({"ok": true}));
|
assert_eq!(results["raw"], serde_json::json!({"ok": true}));
|
||||||
// derived gets source_data from raw (transform is identity for now)
|
// derived should return the value of "raw" from context
|
||||||
assert_eq!(results["derived"], serde_json::json!({"ok": true}));
|
assert_eq!(results["derived"], serde_json::json!({"ok": true}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_fetch_page_data_transform_literal_expression() {
|
||||||
|
use pinakes_plugin_api::{DataSource, Expression};
|
||||||
|
|
||||||
|
use crate::client::ApiClient;
|
||||||
|
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut sources = HashMap::new();
|
||||||
|
sources.insert("raw".to_string(), DataSource::Static {
|
||||||
|
value: serde_json::json!(42),
|
||||||
|
});
|
||||||
|
sources.insert("derived".to_string(), DataSource::Transform {
|
||||||
|
source_name: "raw".to_string(),
|
||||||
|
expression: Expression::Literal(serde_json::json!("constant")),
|
||||||
|
});
|
||||||
|
|
||||||
|
let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap();
|
||||||
|
// A Literal expression returns the literal value, not the source data
|
||||||
|
assert_eq!(results["derived"], serde_json::json!("constant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: multiple Static sources with the same value each get their own
|
||||||
|
// result; dedup logic does not collapse distinct-named Static sources.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_fetch_page_data_deduplicates_identical_endpoints() {
|
||||||
|
use pinakes_plugin_api::DataSource;
|
||||||
|
|
||||||
|
use crate::client::ApiClient;
|
||||||
|
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut sources = HashMap::new();
|
||||||
|
// Two Static sources with the same payload; dedup is for Endpoint sources,
|
||||||
|
// but both names must appear in the output regardless.
|
||||||
|
sources.insert("a".to_string(), DataSource::Static {
|
||||||
|
value: serde_json::json!(1),
|
||||||
|
});
|
||||||
|
sources.insert("b".to_string(), DataSource::Static {
|
||||||
|
value: serde_json::json!(1),
|
||||||
|
});
|
||||||
|
let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap();
|
||||||
|
assert_eq!(results["a"], serde_json::json!(1));
|
||||||
|
assert_eq!(results["b"], serde_json::json!(1));
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Endpoint sources with identical (path, method, params) but different
|
||||||
|
// transform expressions each get a correctly transformed result. Because the
|
||||||
|
// test runs without a real server the path is checked against the allowlist
|
||||||
|
// before any network call, so we verify the dedup key grouping through the
|
||||||
|
// allowlist rejection path: both names should see the same error message,
|
||||||
|
// proving they were grouped and the single rejection propagates to all names.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dedup_groups_endpoint_sources_with_same_key() {
|
||||||
|
use pinakes_plugin_api::{DataSource, Expression, HttpMethod};
|
||||||
|
|
||||||
|
use crate::client::ApiClient;
|
||||||
|
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut sources = HashMap::new();
|
||||||
|
// Two endpoints with identical (path, method, params=empty) but different
|
||||||
|
// transforms. Both should produce the same error when the path is blocked.
|
||||||
|
sources.insert("x".to_string(), DataSource::Endpoint {
|
||||||
|
path: "/api/v1/media".to_string(),
|
||||||
|
method: HttpMethod::Get,
|
||||||
|
params: Default::default(),
|
||||||
|
poll_interval: 0,
|
||||||
|
transform: Some(Expression::Literal(serde_json::json!("from_x"))),
|
||||||
|
});
|
||||||
|
sources.insert("y".to_string(), DataSource::Endpoint {
|
||||||
|
path: "/api/v1/media".to_string(),
|
||||||
|
method: HttpMethod::Get,
|
||||||
|
params: Default::default(),
|
||||||
|
poll_interval: 0,
|
||||||
|
transform: Some(Expression::Literal(serde_json::json!("from_y"))),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both sources point to the same blocked endpoint; expect an error.
|
||||||
|
let allowed = vec!["/api/v1/tags".to_string()];
|
||||||
|
let result = super::fetch_page_data(&client, &sources, &allowed).await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"fetch_page_data must return Err for blocked deduplicated endpoints"
|
||||||
|
);
|
||||||
|
let msg = result.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
msg.contains("not in plugin's declared required_endpoints"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: multiple Transform sources referencing the same upstream Static source
|
||||||
|
// with different expressions each receive their independently transformed
|
||||||
|
// result. This exercises the transform fan-out behavior that mirrors what
|
||||||
|
// the Endpoint dedup group does after a single shared HTTP request completes:
|
||||||
|
// each member of a group applies its own transform to the shared raw value.
|
||||||
|
//
|
||||||
|
// Testing the Endpoint dedup success path with real per-member transforms
|
||||||
|
// requires a mock HTTP server and belongs in an integration test.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dedup_transform_applied_per_source() {
|
||||||
|
use pinakes_plugin_api::{DataSource, Expression};
|
||||||
|
|
||||||
|
use crate::client::ApiClient;
|
||||||
|
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut sources = HashMap::new();
|
||||||
|
sources.insert("raw_data".to_string(), DataSource::Static {
|
||||||
|
value: serde_json::json!({"count": 42, "name": "test"}),
|
||||||
|
});
|
||||||
|
// Two Transform sources referencing "raw_data" with different expressions;
|
||||||
|
// each must produce its own independently derived value.
|
||||||
|
sources.insert("derived_count".to_string(), DataSource::Transform {
|
||||||
|
source_name: "raw_data".to_string(),
|
||||||
|
expression: Expression::Path("raw_data.count".to_string()),
|
||||||
|
});
|
||||||
|
sources.insert("derived_name".to_string(), DataSource::Transform {
|
||||||
|
source_name: "raw_data".to_string(),
|
||||||
|
expression: Expression::Path("raw_data.name".to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let results =
|
||||||
|
super::fetch_page_data(&client, &sources, &[]).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
results["raw_data"],
|
||||||
|
serde_json::json!({"count": 42, "name": "test"})
|
||||||
|
);
|
||||||
|
assert_eq!(results["derived_count"], serde_json::json!(42));
|
||||||
|
assert_eq!(results["derived_name"], serde_json::json!("test"));
|
||||||
|
assert_eq!(results.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: fetch_page_data returns an error when the endpoint data source path is
|
||||||
|
// not listed in the allowed_endpoints slice.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_endpoint_blocked_when_not_in_allowlist() {
|
||||||
|
use pinakes_plugin_api::{DataSource, HttpMethod};
|
||||||
|
|
||||||
|
use crate::client::ApiClient;
|
||||||
|
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut sources = HashMap::new();
|
||||||
|
sources.insert("items".to_string(), DataSource::Endpoint {
|
||||||
|
path: "/api/v1/media".to_string(),
|
||||||
|
method: HttpMethod::Get,
|
||||||
|
params: Default::default(),
|
||||||
|
poll_interval: 0,
|
||||||
|
transform: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide a non-empty allowlist that does NOT include the endpoint path.
|
||||||
|
let allowed = vec!["/api/v1/tags".to_string()];
|
||||||
|
let result = super::fetch_page_data(&client, &sources, &allowed).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"fetch_page_data must return Err when endpoint is not in allowed_endpoints"
|
||||||
|
);
|
||||||
|
let msg = result.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
msg.contains("not in plugin's declared required_endpoints"),
|
||||||
|
"error must explain that the endpoint is not declared, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,18 @@ use crate::client::ApiClient;
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PluginPage {
|
pub struct PluginPage {
|
||||||
/// Plugin ID that provides this page
|
/// Plugin ID that provides this page
|
||||||
pub plugin_id: String,
|
pub plugin_id: String,
|
||||||
/// Page definition from schema
|
/// Page definition from schema
|
||||||
pub page: UiPage,
|
pub page: UiPage,
|
||||||
|
/// Endpoint paths this plugin is allowed to fetch (empty means no
|
||||||
|
/// restriction)
|
||||||
|
pub allowed_endpoints: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginPage {
|
impl PluginPage {
|
||||||
/// Full route including plugin prefix
|
/// The canonical route for this page, taken directly from the page schema.
|
||||||
pub fn full_route(&self) -> String {
|
pub fn full_route(&self) -> String {
|
||||||
format!("/plugins/{}/{}", self.plugin_id, self.page.id)
|
self.page.route.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,10 +49,12 @@ impl PluginPage {
|
||||||
pub struct PluginRegistry {
|
pub struct PluginRegistry {
|
||||||
/// API client for fetching pages from server
|
/// API client for fetching pages from server
|
||||||
client: ApiClient,
|
client: ApiClient,
|
||||||
/// Cached pages: (plugin_id, page_id) -> PluginPage
|
/// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage`
|
||||||
pages: HashMap<(String, String), PluginPage>,
|
pages: HashMap<(String, String), PluginPage>,
|
||||||
/// Cached widgets: (plugin_id, widget_id) -> UiWidget
|
/// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget`
|
||||||
widgets: Vec<(String, UiWidget)>,
|
widgets: Vec<(String, UiWidget)>,
|
||||||
|
/// Merged CSS custom property overrides from all enabled plugins
|
||||||
|
theme_vars: HashMap<String, String>,
|
||||||
/// Last refresh timestamp
|
/// Last refresh timestamp
|
||||||
last_refresh: Option<chrono::DateTime<chrono::Utc>>,
|
last_refresh: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|
@ -61,25 +66,57 @@ impl PluginRegistry {
|
||||||
client,
|
client,
|
||||||
pages: HashMap::new(),
|
pages: HashMap::new(),
|
||||||
widgets: Vec::new(),
|
widgets: Vec::new(),
|
||||||
|
theme_vars: HashMap::new(),
|
||||||
last_refresh: None,
|
last_refresh: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new registry with pre-loaded pages
|
/// Get merged CSS custom property overrides from all loaded plugins.
|
||||||
pub fn with_pages(client: ApiClient, pages: Vec<(String, UiPage)>) -> Self {
|
pub fn theme_vars(&self) -> &HashMap<String, String> {
|
||||||
let mut registry = Self::new(client);
|
&self.theme_vars
|
||||||
for (plugin_id, page) in pages {
|
|
||||||
registry.register_page(plugin_id, page);
|
|
||||||
}
|
|
||||||
registry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a page from a plugin
|
/// Register a page from a plugin
|
||||||
pub fn register_page(&mut self, plugin_id: String, page: UiPage) {
|
///
|
||||||
|
/// Pages that fail schema validation are silently skipped with a warning log.
|
||||||
|
pub fn register_page(
|
||||||
|
&mut self,
|
||||||
|
plugin_id: String,
|
||||||
|
page: UiPage,
|
||||||
|
allowed_endpoints: Vec<String>,
|
||||||
|
) {
|
||||||
|
if let Err(e) = page.validate() {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin_id = %plugin_id,
|
||||||
|
page_id = %page.id,
|
||||||
|
"Skipping invalid page '{}' from '{}': {e}",
|
||||||
|
page.id,
|
||||||
|
plugin_id,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let page_id = page.id.clone();
|
let page_id = page.id.clone();
|
||||||
self
|
// Check for duplicate page_id across different plugins. Same-plugin
|
||||||
.pages
|
// re-registration of the same page is allowed to overwrite.
|
||||||
.insert((plugin_id.clone(), page_id), PluginPage { plugin_id, page });
|
let has_duplicate = self.pages.values().any(|existing| {
|
||||||
|
existing.page.id == page_id && existing.plugin_id != plugin_id
|
||||||
|
});
|
||||||
|
if has_duplicate {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin_id = %plugin_id,
|
||||||
|
page_id = %page_id,
|
||||||
|
"skipping plugin page: page ID conflicts with an existing page from another plugin"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.pages.insert(
|
||||||
|
(plugin_id.clone(), page_id),
|
||||||
|
PluginPage {
|
||||||
|
plugin_id,
|
||||||
|
page,
|
||||||
|
allowed_endpoints,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a specific page by plugin ID and page ID
|
/// Get a specific page by plugin ID and page ID
|
||||||
|
|
@ -94,29 +131,37 @@ impl PluginRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a widget from a plugin
|
/// Register a widget from a plugin
|
||||||
|
///
|
||||||
|
/// Widgets that fail schema validation are silently skipped with a warning
|
||||||
|
/// log.
|
||||||
pub fn register_widget(&mut self, plugin_id: String, widget: UiWidget) {
|
pub fn register_widget(&mut self, plugin_id: String, widget: UiWidget) {
|
||||||
|
if let Err(e) = widget.validate() {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin_id = %plugin_id,
|
||||||
|
widget_id = %widget.id,
|
||||||
|
"Skipping invalid widget '{}' from '{}': {e}",
|
||||||
|
widget.id,
|
||||||
|
plugin_id,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.widgets.push((plugin_id, widget));
|
self.widgets.push((plugin_id, widget));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all widgets (for use with WidgetContainer)
|
/// Get all widgets (for use with `WidgetContainer`)
|
||||||
pub fn all_widgets(&self) -> Vec<(String, UiWidget)> {
|
pub fn all_widgets(&self) -> Vec<(String, UiWidget)> {
|
||||||
self.widgets.clone()
|
self.widgets.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all pages
|
/// Get all pages
|
||||||
|
#[allow(
|
||||||
|
dead_code,
|
||||||
|
reason = "used in tests and may be needed by future callers"
|
||||||
|
)]
|
||||||
pub fn all_pages(&self) -> Vec<&PluginPage> {
|
pub fn all_pages(&self) -> Vec<&PluginPage> {
|
||||||
self.pages.values().collect()
|
self.pages.values().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all page routes for navigation
|
|
||||||
pub fn routes(&self) -> Vec<(String, String, String)> {
|
|
||||||
self
|
|
||||||
.pages
|
|
||||||
.values()
|
|
||||||
.map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.full_route()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if any pages are registered
|
/// Check if any pages are registered
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.pages.is_empty()
|
self.pages.is_empty()
|
||||||
|
|
@ -127,20 +172,50 @@ impl PluginRegistry {
|
||||||
self.pages.len()
|
self.pages.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh pages from server
|
/// Get all page routes for navigation
|
||||||
|
///
|
||||||
|
/// Returns `(plugin_id, page_id, full_route)` triples.
|
||||||
|
pub fn routes(&self) -> Vec<(String, String, String)> {
|
||||||
|
self
|
||||||
|
.pages
|
||||||
|
.values()
|
||||||
|
.map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.full_route()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh pages and widgets from server
|
||||||
pub async fn refresh(&mut self) -> Result<(), String> {
|
pub async fn refresh(&mut self) -> Result<(), String> {
|
||||||
match self.client.get_plugin_ui_pages().await {
|
let pages = self
|
||||||
Ok(pages) => {
|
.client
|
||||||
self.pages.clear();
|
.get_plugin_ui_pages()
|
||||||
self.widgets.clear();
|
.await
|
||||||
for (plugin_id, page) in pages {
|
.map_err(|e| format!("Failed to refresh plugin pages: {e}"))?;
|
||||||
self.register_page(plugin_id, page);
|
|
||||||
}
|
// Build into a temporary registry to avoid a window where state appears
|
||||||
self.last_refresh = Some(chrono::Utc::now());
|
// empty during the two async fetches.
|
||||||
Ok(())
|
let mut tmp = Self::new(self.client.clone());
|
||||||
},
|
for (plugin_id, page, endpoints) in pages {
|
||||||
Err(e) => Err(format!("Failed to refresh plugin pages: {e}")),
|
tmp.register_page(plugin_id, page, endpoints);
|
||||||
}
|
}
|
||||||
|
match self.client.get_plugin_ui_widgets().await {
|
||||||
|
Ok(widgets) => {
|
||||||
|
for (plugin_id, widget) in widgets {
|
||||||
|
tmp.register_widget(plugin_id, widget);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => tracing::warn!("Failed to refresh plugin widgets: {e}"),
|
||||||
|
}
|
||||||
|
match self.client.get_plugin_ui_theme_extensions().await {
|
||||||
|
Ok(vars) => tmp.theme_vars = vars,
|
||||||
|
Err(e) => tracing::warn!("Failed to refresh plugin theme extensions: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic swap: no window where the registry appears empty.
|
||||||
|
self.pages = tmp.pages;
|
||||||
|
self.widgets = tmp.widgets;
|
||||||
|
self.theme_vars = tmp.theme_vars;
|
||||||
|
self.last_refresh = Some(chrono::Utc::now());
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get last refresh time
|
/// Get last refresh time
|
||||||
|
|
@ -173,6 +248,7 @@ mod tests {
|
||||||
padding: None,
|
padding: None,
|
||||||
},
|
},
|
||||||
data_sources: HashMap::new(),
|
data_sources: HashMap::new(),
|
||||||
|
actions: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,7 +257,7 @@ mod tests {
|
||||||
let client = ApiClient::default();
|
let client = ApiClient::default();
|
||||||
let registry = PluginRegistry::new(client);
|
let registry = PluginRegistry::new(client);
|
||||||
assert!(registry.is_empty());
|
assert!(registry.is_empty());
|
||||||
assert_eq!(registry.len(), 0);
|
assert_eq!(registry.all_pages().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -190,10 +266,10 @@ mod tests {
|
||||||
let mut registry = PluginRegistry::new(client);
|
let mut registry = PluginRegistry::new(client);
|
||||||
let page = create_test_page("demo", "Demo Page");
|
let page = create_test_page("demo", "Demo Page");
|
||||||
|
|
||||||
registry.register_page("my-plugin".to_string(), page.clone());
|
registry.register_page("my-plugin".to_string(), page.clone(), vec![]);
|
||||||
|
|
||||||
assert!(!registry.is_empty());
|
assert!(!registry.is_empty());
|
||||||
assert_eq!(registry.len(), 1);
|
assert_eq!(registry.all_pages().len(), 1);
|
||||||
|
|
||||||
let retrieved = registry.get_page("my-plugin", "demo");
|
let retrieved = registry.get_page("my-plugin", "demo");
|
||||||
assert!(retrieved.is_some());
|
assert!(retrieved.is_some());
|
||||||
|
|
@ -210,18 +286,6 @@ mod tests {
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_page_full_route() {
|
|
||||||
let client = ApiClient::default();
|
|
||||||
let mut registry = PluginRegistry::new(client);
|
|
||||||
let page = create_test_page("demo", "Demo Page");
|
|
||||||
|
|
||||||
registry.register_page("my-plugin".to_string(), page.clone());
|
|
||||||
|
|
||||||
let plugin_page = registry.get_page("my-plugin", "demo").unwrap();
|
|
||||||
assert_eq!(plugin_page.full_route(), "/plugins/my-plugin/demo");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_all_pages() {
|
fn test_all_pages() {
|
||||||
let client = ApiClient::default();
|
let client = ApiClient::default();
|
||||||
|
|
@ -230,33 +294,18 @@ mod tests {
|
||||||
registry.register_page(
|
registry.register_page(
|
||||||
"plugin1".to_string(),
|
"plugin1".to_string(),
|
||||||
create_test_page("page1", "Page 1"),
|
create_test_page("page1", "Page 1"),
|
||||||
|
vec![],
|
||||||
);
|
);
|
||||||
registry.register_page(
|
registry.register_page(
|
||||||
"plugin2".to_string(),
|
"plugin2".to_string(),
|
||||||
create_test_page("page2", "Page 2"),
|
create_test_page("page2", "Page 2"),
|
||||||
|
vec![],
|
||||||
);
|
);
|
||||||
|
|
||||||
let all = registry.all_pages();
|
let all = registry.all_pages();
|
||||||
assert_eq!(all.len(), 2);
|
assert_eq!(all.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_routes() {
|
|
||||||
let client = ApiClient::default();
|
|
||||||
let mut registry = PluginRegistry::new(client);
|
|
||||||
|
|
||||||
registry.register_page(
|
|
||||||
"plugin1".to_string(),
|
|
||||||
create_test_page("page1", "Page 1"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let routes = registry.routes();
|
|
||||||
assert_eq!(routes.len(), 1);
|
|
||||||
assert_eq!(routes[0].0, "plugin1");
|
|
||||||
assert_eq!(routes[0].1, "page1");
|
|
||||||
assert_eq!(routes[0].2, "/plugins/plugin1/page1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_register_widget_and_all_widgets() {
|
fn test_register_widget_and_all_widgets() {
|
||||||
let client = ApiClient::default();
|
let client = ApiClient::default();
|
||||||
|
|
@ -277,31 +326,23 @@ mod tests {
|
||||||
assert_eq!(widgets[0].1.id, "my-widget");
|
assert_eq!(widgets[0].1.id, "my-widget");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_with_pages_builds_registry() {
|
|
||||||
let client = ApiClient::default();
|
|
||||||
let pages = vec![
|
|
||||||
("plugin1".to_string(), create_test_page("page1", "Page 1")),
|
|
||||||
("plugin2".to_string(), create_test_page("page2", "Page 2")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let registry = PluginRegistry::with_pages(client, pages);
|
|
||||||
assert_eq!(registry.len(), 2);
|
|
||||||
assert!(registry.get_page("plugin1", "page1").is_some());
|
|
||||||
assert!(registry.get_page("plugin2", "page2").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_register_page_overwrites_same_key() {
|
fn test_register_page_overwrites_same_key() {
|
||||||
let client = ApiClient::default();
|
let client = ApiClient::default();
|
||||||
let mut registry = PluginRegistry::new(client);
|
let mut registry = PluginRegistry::new(client);
|
||||||
|
|
||||||
registry
|
registry.register_page(
|
||||||
.register_page("plugin1".to_string(), create_test_page("p", "Original"));
|
"plugin1".to_string(),
|
||||||
registry
|
create_test_page("p", "Original"),
|
||||||
.register_page("plugin1".to_string(), create_test_page("p", "Updated"));
|
vec![],
|
||||||
|
);
|
||||||
|
registry.register_page(
|
||||||
|
"plugin1".to_string(),
|
||||||
|
create_test_page("p", "Updated"),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
|
||||||
assert_eq!(registry.len(), 1);
|
assert_eq!(registry.all_pages().len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.get_page("plugin1", "p").unwrap().page.title,
|
registry.get_page("plugin1", "p").unwrap().page.title,
|
||||||
"Updated"
|
"Updated"
|
||||||
|
|
@ -312,16 +353,73 @@ mod tests {
|
||||||
fn test_default_registry_is_empty() {
|
fn test_default_registry_is_empty() {
|
||||||
let registry = PluginRegistry::default();
|
let registry = PluginRegistry::default();
|
||||||
assert!(registry.is_empty());
|
assert!(registry.is_empty());
|
||||||
assert_eq!(registry.len(), 0);
|
assert_eq!(registry.all_pages().len(), 0);
|
||||||
assert!(registry.last_refresh().is_none());
|
assert!(registry.last_refresh().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_len() {
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut registry = PluginRegistry::new(client);
|
||||||
|
assert_eq!(registry.len(), 0);
|
||||||
|
registry.register_page("p".to_string(), create_test_page("a", "A"), vec![]);
|
||||||
|
assert_eq!(registry.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_full_route() {
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut registry = PluginRegistry::new(client);
|
||||||
|
registry.register_page(
|
||||||
|
"my-plugin".to_string(),
|
||||||
|
create_test_page("demo", "Demo Page"),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let plugin_page = registry.get_page("my-plugin", "demo").unwrap();
|
||||||
|
// full_route() returns page.route directly; create_test_page sets it as
|
||||||
|
// "/plugins/test/{id}"
|
||||||
|
assert_eq!(plugin_page.full_route(), "/plugins/test/demo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routes() {
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut registry = PluginRegistry::new(client);
|
||||||
|
registry.register_page(
|
||||||
|
"plugin1".to_string(),
|
||||||
|
create_test_page("page1", "Page 1"),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let routes = registry.routes();
|
||||||
|
assert_eq!(routes.len(), 1);
|
||||||
|
assert_eq!(routes[0].0, "plugin1");
|
||||||
|
assert_eq!(routes[0].1, "page1");
|
||||||
|
assert_eq!(routes[0].2, "/plugins/test/page1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_with_pages_builds_registry() {
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let pages = vec![
|
||||||
|
("plugin1".to_string(), create_test_page("page1", "Page 1")),
|
||||||
|
("plugin2".to_string(), create_test_page("page2", "Page 2")),
|
||||||
|
];
|
||||||
|
// Build via register_page loop (equivalent to old with_pages)
|
||||||
|
let mut registry = PluginRegistry::new(client);
|
||||||
|
for (plugin_id, page) in pages {
|
||||||
|
registry.register_page(plugin_id, page, vec![]);
|
||||||
|
}
|
||||||
|
assert_eq!(registry.len(), 2);
|
||||||
|
assert!(registry.get_page("plugin1", "page1").is_some());
|
||||||
|
assert!(registry.get_page("plugin2", "page2").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_all_pages_returns_references() {
|
fn test_all_pages_returns_references() {
|
||||||
let client = ApiClient::default();
|
let client = ApiClient::default();
|
||||||
let mut registry = PluginRegistry::new(client);
|
let mut registry = PluginRegistry::new(client);
|
||||||
registry.register_page("p1".to_string(), create_test_page("a", "A"));
|
registry.register_page("p1".to_string(), create_test_page("a", "A"), vec![]);
|
||||||
registry.register_page("p2".to_string(), create_test_page("b", "B"));
|
registry.register_page("p2".to_string(), create_test_page("b", "B"), vec![]);
|
||||||
|
|
||||||
let pages = registry.all_pages();
|
let pages = registry.all_pages();
|
||||||
assert_eq!(pages.len(), 2);
|
assert_eq!(pages.len(), 2);
|
||||||
|
|
@ -332,27 +430,145 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_different_plugins_same_page_id_both_stored() {
|
fn test_different_plugins_same_page_id_second_rejected() {
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut registry = PluginRegistry::new(client);
|
||||||
|
|
||||||
|
// First plugin registers "stats" - should succeed.
|
||||||
|
registry.register_page(
|
||||||
|
"plugin-a".to_string(),
|
||||||
|
create_test_page("stats", "A Stats"),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
// Second plugin attempts to register the same page ID "stats" - should be
|
||||||
|
// rejected to avoid route collisions at /plugins/stats.
|
||||||
|
registry.register_page(
|
||||||
|
"plugin-b".to_string(),
|
||||||
|
create_test_page("stats", "B Stats"),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only one page should be registered; the second was rejected.
|
||||||
|
assert_eq!(registry.all_pages().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
registry.get_page("plugin-a", "stats").unwrap().page.title,
|
||||||
|
"A Stats"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
registry.get_page("plugin-b", "stats").is_none(),
|
||||||
|
"plugin-b's page with duplicate ID should have been rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_same_plugin_same_page_id_overwrites() {
|
||||||
|
// Same plugin re-registering the same page ID should still be allowed
|
||||||
|
// (overwrite semantics, not a cross-plugin conflict).
|
||||||
let client = ApiClient::default();
|
let client = ApiClient::default();
|
||||||
let mut registry = PluginRegistry::new(client);
|
let mut registry = PluginRegistry::new(client);
|
||||||
|
|
||||||
registry.register_page(
|
registry.register_page(
|
||||||
"plugin-a".to_string(),
|
"plugin-a".to_string(),
|
||||||
create_test_page("home", "A Home"),
|
create_test_page("stats", "A Stats v1"),
|
||||||
|
vec![],
|
||||||
);
|
);
|
||||||
registry.register_page(
|
registry.register_page(
|
||||||
"plugin-b".to_string(),
|
"plugin-a".to_string(),
|
||||||
create_test_page("home", "B Home"),
|
create_test_page("stats", "A Stats v2"),
|
||||||
|
vec![],
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(registry.len(), 2);
|
assert_eq!(registry.all_pages().len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.get_page("plugin-a", "home").unwrap().page.title,
|
registry.get_page("plugin-a", "stats").unwrap().page.title,
|
||||||
"A Home"
|
"A Stats v2"
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
registry.get_page("plugin-b", "home").unwrap().page.title,
|
|
||||||
"B Home"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_register_invalid_page_is_skipped() {
|
||||||
|
use pinakes_plugin_api::UiElement;
|
||||||
|
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut registry = PluginRegistry::new(client);
|
||||||
|
|
||||||
|
// A page with an empty ID fails validation
|
||||||
|
let invalid_page = UiPage {
|
||||||
|
id: String::new(), // invalid: empty
|
||||||
|
title: "Bad Page".to_string(),
|
||||||
|
route: "/plugins/bad".to_string(),
|
||||||
|
icon: None,
|
||||||
|
root_element: UiElement::Container {
|
||||||
|
children: vec![],
|
||||||
|
gap: 16,
|
||||||
|
padding: None,
|
||||||
|
},
|
||||||
|
data_sources: HashMap::new(),
|
||||||
|
actions: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.register_page("test-plugin".to_string(), invalid_page, vec![]);
|
||||||
|
assert!(registry.is_empty(), "invalid page should have been skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_register_valid_page_after_invalid() {
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut registry = PluginRegistry::new(client);
|
||||||
|
|
||||||
|
use pinakes_plugin_api::UiElement;
|
||||||
|
|
||||||
|
// Invalid page
|
||||||
|
let invalid_page = UiPage {
|
||||||
|
id: String::new(),
|
||||||
|
title: "Bad".to_string(),
|
||||||
|
route: "/bad".to_string(),
|
||||||
|
icon: None,
|
||||||
|
root_element: UiElement::Container {
|
||||||
|
children: vec![],
|
||||||
|
gap: 0,
|
||||||
|
padding: None,
|
||||||
|
},
|
||||||
|
data_sources: HashMap::new(),
|
||||||
|
actions: HashMap::new(),
|
||||||
|
};
|
||||||
|
registry.register_page("p".to_string(), invalid_page, vec![]);
|
||||||
|
assert_eq!(registry.all_pages().len(), 0);
|
||||||
|
|
||||||
|
// Valid page; should still register fine
|
||||||
|
registry.register_page("p".to_string(), create_test_page("good", "Good"), vec![]);
|
||||||
|
assert_eq!(registry.all_pages().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_register_invalid_widget_is_skipped() {
|
||||||
|
let client = ApiClient::default();
|
||||||
|
let mut registry = PluginRegistry::new(client);
|
||||||
|
|
||||||
|
let widget: pinakes_plugin_api::UiWidget =
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "my-widget",
|
||||||
|
"target": "library_header",
|
||||||
|
"content": { "type": "badge", "text": "hi", "variant": "default" }
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Mutate: create an invalid widget with empty id
|
||||||
|
let invalid_widget = pinakes_plugin_api::UiWidget {
|
||||||
|
id: String::new(), // invalid
|
||||||
|
target: "library_header".to_string(),
|
||||||
|
content: widget.content.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(registry.all_widgets().is_empty());
|
||||||
|
registry.register_widget("test-plugin".to_string(), invalid_widget);
|
||||||
|
assert!(
|
||||||
|
registry.all_widgets().is_empty(),
|
||||||
|
"invalid widget should have been skipped"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Valid widget is still accepted
|
||||||
|
registry.register_widget("test-plugin".to_string(), widget);
|
||||||
|
assert_eq!(registry.all_widgets().len(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue