treewide: cleanup

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia01590cdeed872cc8ebd16f6ca95f3cc6a6a6964
This commit is contained in:
raf 2026-03-11 17:23:51 +03:00
commit 185e3b562a
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
16 changed files with 258 additions and 219 deletions

View file

@ -2,7 +2,10 @@
//!
//! Provides data fetching and caching for plugin data sources.
use std::{collections::HashMap, time::Duration};
use std::{
collections::{HashMap, HashSet},
time::Duration,
};
use dioxus::prelude::*;
use dioxus_core::Task;
@ -15,7 +18,7 @@ use crate::client::ApiClient;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginPageData {
data: HashMap<String, serde_json::Value>,
loading: HashMap<String, bool>,
loading: HashSet<String>,
errors: HashMap<String, String>,
}
@ -29,13 +32,13 @@ impl PluginPageData {
/// Check if a source is currently loading
#[must_use]
pub fn is_loading(&self, source: &str) -> bool {
self.loading.get(source).copied().unwrap_or(false)
self.loading.contains(source)
}
/// Get error for a specific source
#[must_use]
pub fn error(&self, source: &str) -> Option<&String> {
self.errors.get(source)
pub fn error(&self, source: &str) -> Option<&str> {
self.errors.get(source).map(String::as_str)
}
/// Check if there is data for a specific source
@ -52,7 +55,7 @@ impl PluginPageData {
/// Set loading state for a source
pub fn set_loading(&mut self, source: &str, loading: bool) {
if loading {
self.loading.insert(source.to_string(), true);
self.loading.insert(source.to_string());
self.errors.remove(source);
} else {
self.loading.remove(source);
@ -161,9 +164,10 @@ async fn fetch_endpoint(
///
/// 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.
/// 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
///
@ -263,8 +267,15 @@ pub async fn fetch_page_data(
..
} => {
let empty_ctx = serde_json::json!({});
fetch_endpoint(&client, path, method.clone(), params, &empty_ctx, &allowed)
.await?
fetch_endpoint(
&client,
path,
method.clone(),
params,
&empty_ctx,
&allowed,
)
.await?
},
DataSource::Static { value } => value.clone(),
DataSource::Transform { .. } => unreachable!(),
@ -296,21 +307,60 @@ pub async fn fetch_page_data(
}
}
// 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(),
// Process Transform sources in dependency order. HashMap iteration order is
// non-deterministic, so a Transform referencing another Transform could see
// null if the upstream was not yet resolved. The pending loop below defers
// any Transform whose upstream is not yet in results, making progress on
// each pass until all are resolved. UiPage::validate guarantees no cycles,
// so the loop always terminates.
let mut pending: Vec<(&String, &String, &Expression)> = data_sources
.iter()
.filter_map(|(name, source)| {
match source {
DataSource::Transform {
source_name,
expression,
} => Some((name, source_name, expression)),
_ => None,
}
})
.collect();
while !pending.is_empty() {
let prev_len = pending.len();
let mut i = 0;
while i < pending.len() {
let (name, source_name, expression) = pending[i];
if results.contains_key(source_name.as_str()) {
let ctx = serde_json::Value::Object(
results
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
results.insert(name.clone(), evaluate_expression(expression, &ctx));
pending.swap_remove(i);
} else {
i += 1;
}
}
if pending.len() == prev_len {
// No progress: upstream source is missing (should be caught by
// UiPage::validate, but handled defensively here).
tracing::warn!(
"plugin transform dependency unresolvable; processing remaining in \
iteration order"
);
let _ = source_name; // accessible in ctx by its key
results.insert(name.clone(), evaluate_expression(expression, &ctx));
for (name, _, expression) in pending {
let ctx = serde_json::Value::Object(
results
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
results.insert(name.clone(), evaluate_expression(expression, &ctx));
}
break;
}
}
@ -446,7 +496,7 @@ mod tests {
// Test error state
data.set_error("error".to_string(), "oops".to_string());
assert_eq!(data.error("error"), Some(&"oops".to_string()));
assert_eq!(data.error("error"), Some("oops"));
}
#[test]
@ -522,7 +572,9 @@ mod tests {
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["flag"], serde_json::json!(true));
}
@ -544,7 +596,9 @@ mod tests {
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();
assert_eq!(results["raw"], serde_json::json!({"ok": true}));
// derived should return the value of "raw" from context
assert_eq!(results["derived"], serde_json::json!({"ok": true}));
@ -566,13 +620,13 @@ mod tests {
expression: Expression::Literal(serde_json::json!("constant")),
});
let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap();
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;
@ -589,18 +643,18 @@ mod tests {
sources.insert("b".to_string(), DataSource::Static {
value: serde_json::json!(1),
});
let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap();
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.
// Verifies that endpoint sources with identical (path, method, params) are
// deduplicated correctly. Because there is no real server, the allowlist
// rejection fires before any network call; both names seeing the same error
// proves they were grouped and that the single rejection propagated to all.
#[tokio::test]
async fn test_dedup_groups_endpoint_sources_with_same_key() {
use pinakes_plugin_api::{DataSource, Expression, HttpMethod};
@ -640,14 +694,12 @@ mod tests {
);
}
// 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.
// Verifies the transform fan-out behavior: each member of a dedup group
// applies its own transform to the shared raw value independently. This
// mirrors what Endpoint dedup does after a single shared HTTP request.
//
// Testing the Endpoint dedup success path with real per-member transforms
// requires a mock HTTP server and belongs in an integration test.
// Testing Endpoint dedup 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};
@ -670,8 +722,9 @@ mod tests {
expression: Expression::Path("raw_data.name".to_string()),
});
let results =
super::fetch_page_data(&client, &sources, &[]).await.unwrap();
let results = super::fetch_page_data(&client, &sources, &[])
.await
.unwrap();
assert_eq!(
results["raw_data"],
serde_json::json!({"count": 42, "name": "test"})
@ -681,8 +734,6 @@ mod tests {
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};
@ -705,7 +756,8 @@ mod tests {
assert!(
result.is_err(),
"fetch_page_data must return Err when endpoint is not in allowed_endpoints"
"fetch_page_data must return Err when endpoint is not in \
allowed_endpoints"
);
let msg = result.unwrap_err();
assert!(