diff --git a/Cargo.lock b/Cargo.lock index 7456c8a7..4eae50fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1762,7 +1762,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "marketplace-api" -version = "2.0.0" +version = "3.0.0" dependencies = [ "actix-web", "anyhow", diff --git a/DESIGN.md b/DESIGN.md index edbe0de6..2ea93060 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -442,17 +442,19 @@ pub struct Predicate { - Attribute names starting with '?' are treated as boolean attributes using the `Attribute` fragment type with a `condition_tree`. The attribute is rendered only if the condition evaluates to true. ## State Management (webui-state) -### Path Resolution -The `find_value_by_dotted_path` function provides a high-performance way to query JSON state: -```rust -pub fn find_value_by_dotted_path(path: &str, state: &Value) -> Result -``` -### Requirements -- Dot notation support (e.g., user.profile.name) -- Array indexing support (e.g., users.0.name) -- Special length property support for arrays (e.g., users.length) -- Nullable path handling -- Missing path error reporting +### Path Resolution +The `find_value_by_dotted_path_ref` function provides the render-time state lookup contract: +```rust +pub fn find_value_by_dotted_path_ref<'a>(path: &str, state: &'a Value) -> Option> +``` +Existing JSON values are returned as `Cow::Borrowed` so handler and expression hot paths do not clone the state tree. Synthetic values, currently string and array `.length`, are returned as `Cow::Owned`. The owned `find_value_by_dotted_path(path, state) -> Option` wrapper is retained for API boundaries that must materialize an owned `serde_json::Value`. + +### Requirements +- Dot notation support (e.g., user.profile.name) +- Special length property support for arrays and strings (e.g., users.length) +- Numeric array indexes are not resolved by dotted path lookup; loops bind array items by moniker instead +- Nullable path handling via `Option` +- Missing paths return `None`; handler text and attribute bindings render empty, and missing condition values evaluate as false ## Expression Evaluation (webui-expressions) ### Core Function diff --git a/crates/webui-expressions/src/lib.rs b/crates/webui-expressions/src/lib.rs index b62e16a7..fd9ee4ae 100644 --- a/crates/webui-expressions/src/lib.rs +++ b/crates/webui-expressions/src/lib.rs @@ -5,13 +5,15 @@ //! //! This module handles the evaluation of condition expressions in WebUI templates. +use std::borrow::Cow; + use serde_json::Value; use thiserror::Error; use webui_protocol::{ condition_expr, ComparisonOperator, CompoundCondition, ConditionExpr, LogicalOperator, Predicate, }; -use webui_state::find_value_by_dotted_path; +use webui_state::find_value_by_dotted_path_ref; /// Error types for expression evaluation. #[derive(Debug, Error)] @@ -39,7 +41,7 @@ pub type Result = std::result::Result; /// Evaluate a condition expression with the given state pub fn evaluate(condition: &ConditionExpr, state: &Value) -> Result { - evaluate_with_resolver(condition, |path| find_value_by_dotted_path(path, state)) + evaluate_with_resolver(condition, |path| find_value_by_dotted_path_ref(path, state)) } /// Evaluate a condition expression using a custom resolver for value lookups. @@ -48,9 +50,9 @@ pub fn evaluate(condition: &ConditionExpr, state: &Value) -> Result { /// returns the resolved value. This allows callers to provide merged views /// (e.g., local variables overlaid on global state) without cloning the /// entire state tree. -pub fn evaluate_with_resolver(condition: &ConditionExpr, resolver: F) -> Result +pub fn evaluate_with_resolver<'a, F>(condition: &ConditionExpr, resolver: F) -> Result where - F: Fn(&str) -> Option, + F: Fn(&str) -> Option>, { let (logical_op_count, has_mixed_ops) = count_logical_operators(condition); @@ -109,9 +111,9 @@ fn count_logical_operators(condition: &ConditionExpr) -> (usize, bool) { } // Iterative evaluation of expressions using a resolver closure -fn evaluate_expr(condition: &ConditionExpr, resolver: &F) -> Result +fn evaluate_expr<'a, F>(condition: &ConditionExpr, resolver: &F) -> Result where - F: Fn(&str) -> Option, + F: Fn(&str) -> Option>, { match &condition.expr { Some(condition_expr::Expr::Predicate(pred)) => evaluate_predicate(pred, resolver), @@ -125,8 +127,8 @@ where Some(condition_expr::Expr::Compound(compound)) => evaluate_compound(compound, resolver), Some(condition_expr::Expr::Identifier(id)) => { if let Some(val) = resolver(&id.value) { - match val { - Value::Bool(b) => Ok(b), + match val.as_ref() { + Value::Bool(b) => Ok(*b), Value::Null => Ok(false), Value::Number(n) => Ok(!(n.as_f64() == Some(0.0))), Value::String(s) => Ok(!s.is_empty()), @@ -143,9 +145,9 @@ where } } -fn evaluate_compound(compound: &CompoundCondition, resolver: &F) -> Result +fn evaluate_compound<'a, F>(compound: &CompoundCondition, resolver: &F) -> Result where - F: Fn(&str) -> Option, + F: Fn(&str) -> Option>, { let left = compound.left.as_ref().ok_or_else(|| { ExpressionError::Evaluation("Compound missing left expression".to_string()) @@ -178,9 +180,9 @@ where } } -fn evaluate_predicate(predicate: &Predicate, resolver: &F) -> Result +fn evaluate_predicate<'a, F>(predicate: &Predicate, resolver: &F) -> Result where - F: Fn(&str) -> Option, + F: Fn(&str) -> Option>, { let left_val = match resolver(&predicate.left) { Some(val) => val, @@ -188,7 +190,7 @@ where }; let right_val = if is_literal(&predicate.right) { - parse_literal(&predicate.right)? + Cow::Owned(parse_literal(&predicate.right)?) } else { match resolver(&predicate.right) { Some(val) => val, @@ -203,7 +205,7 @@ where )) })?; - compare_values(&left_val, &op, &right_val) + compare_values(left_val.as_ref(), &op, right_val.as_ref()) } // Check if a string is a literal value @@ -323,6 +325,7 @@ fn extract_number(val: &Value) -> Result { #[cfg(test)] mod tests { use super::*; + use std::borrow::Cow; use webui_protocol::{ComparisonOperator, ConditionExpr, LogicalOperator}; use webui_test_utils::test_json; @@ -601,6 +604,22 @@ mod tests { ); } + #[test] + fn test_borrowed_resolver_value() { + let condition = ConditionExpr::identifier("flag"); + let value = Value::Bool(true); + + let result = evaluate_with_resolver(&condition, |path| { + if path == "flag" { + Some(Cow::Borrowed(&value)) + } else { + None + } + }); + + assert!(matches!(result, Ok(true))); + } + #[test] fn test_missing_value() { let state = test_json!({ diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs index f2e188f9..7c9cdc68 100644 --- a/crates/webui-handler/src/lib.rs +++ b/crates/webui-handler/src/lib.rs @@ -14,12 +14,14 @@ pub(crate) mod route_renderer; use plugin::HandlerPlugin; use route_matcher::CompiledRouteCache; +use serde::Serialize; use serde_json::Value; +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use thiserror::Error; use webui_expressions::{evaluate_with_resolver, ExpressionError}; use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIProtocol}; -use webui_state::find_value_by_dotted_path; +use webui_state::find_value_by_dotted_path_ref; /// Error types for the WebUI handler. #[derive(Debug, Error)] @@ -142,6 +144,15 @@ struct WebUIProcessContext<'a> { route_chain_index: usize, } +struct WebUiBootstrap<'a> { + state: &'a Value, + chain: &'a [Value], + inventory: &'a str, + nonce: Option<&'a str>, + css_hrefs: &'a [&'a str], + style_specs: &'a [&'a str], +} + /// Get the component attribute name, stripping `:` prefix and converting to camelCase. /// /// Uses `webui_protocol::attrs::attribute_to_camel` which handles irregular @@ -175,6 +186,112 @@ fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> { } } +fn write_script_safe_json(writer: &mut dyn ResponseWriter, value: &T) -> Result<()> +where + T: Serialize + ?Sized, +{ + let mut json = Vec::with_capacity(256); + serde_json::to_writer(&mut json, value) + .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?; + let json = std::str::from_utf8(&json) + .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?; + write_script_safe_json_str(writer, json) +} + +fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> { + let mut start = 0; + while start < json.len() { + let rest = &json[start..]; + let Some(offset) = rest.find(" 0 { + writer.write(&rest[..offset])?; + } + writer.write("<\\/")?; + start += offset + 2; + } + Ok(()) +} + +fn write_json_field_name( + writer: &mut dyn ResponseWriter, + wrote_field: &mut bool, + name: &str, +) -> Result<()> { + if *wrote_field { + writer.write(",")?; + } + *wrote_field = true; + writer.write("\"")?; + writer.write(name)?; + writer.write("\":") +} + +fn write_json_field( + writer: &mut dyn ResponseWriter, + wrote_field: &mut bool, + name: &str, + value: &T, +) -> Result<()> +where + T: Serialize + ?Sized, +{ + write_json_field_name(writer, wrote_field, name)?; + write_script_safe_json(writer, value) +} + +fn write_webui_bootstrap( + writer: &mut dyn ResponseWriter, + bootstrap: WebUiBootstrap<'_>, +) -> Result<()> { + let mut wrote_field = false; + + writer.write("{")?; + if !bootstrap.chain.is_empty() { + write_json_field(writer, &mut wrote_field, "chain", bootstrap.chain)?; + } + if !bootstrap.css_hrefs.is_empty() { + write_json_field(writer, &mut wrote_field, "css", bootstrap.css_hrefs)?; + } + write_json_field(writer, &mut wrote_field, "inventory", bootstrap.inventory)?; + if let Some(nonce) = bootstrap.nonce { + write_json_field(writer, &mut wrote_field, "nonce", nonce)?; + } + write_json_field(writer, &mut wrote_field, "state", bootstrap.state)?; + if !bootstrap.style_specs.is_empty() { + write_json_field(writer, &mut wrote_field, "styles", bootstrap.style_specs)?; + } + write_json_field_name(writer, &mut wrote_field, "templates")?; + writer.write("{}")?; + writer.write("}") +} + +fn resolve_value_from_sources<'ctx, 'state>( + path: &str, + local_vars: &'ctx HashMap, + state: &'state Value, +) -> Option> +where + 'state: 'ctx, +{ + if let Some(first_part) = path.split('.').next() { + if let Some(local_value) = local_vars.get(first_part) { + if first_part.len() == path.len() { + return Some(Cow::Borrowed(local_value)); + } + let remaining = &path[first_part.len() + 1..]; + if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) { + return Some(value); + } + } + } + + find_value_by_dotted_path_ref(path, state) +} + impl WebUIHandler { /// Create a new WebUI handler with no plugin. pub fn new() -> Self { @@ -365,10 +482,10 @@ impl WebUIHandler { let request_segments = route_matcher::split_request_path(&context.request_path); let mut best: Option<(usize, route_matcher::RouteMatch)> = None; for (idx, child) in children.iter().enumerate() { - let resolved = route_matcher::resolve_route_path(&child.path, &context.route_base); + let resolved = route_matcher::resolve_route_path_cow(&child.path, &context.route_base); if let Some(m) = route_matcher::match_route_cached_with_segments( &mut context.route_cache, - &resolved, + resolved.as_ref(), &request_segments, child.exact, ) { @@ -611,7 +728,7 @@ impl WebUIHandler { let saved_local_vars = std::mem::take(&mut context.local_vars); let saved_component_attrs = std::mem::take(&mut context.component_attrs); - // Component gets accumulated attrs as its local vars + // Component gets accumulated attrs as its local vars. context.local_vars = saved_component_attrs; if let Some(p) = &mut context.plugin { @@ -632,21 +749,8 @@ impl WebUIHandler { } /// Resolve a dotted path value, checking local variables first, then global state. - fn resolve_value(&self, path: &str, context: &WebUIProcessContext) -> Option { - // Check local vars first - if let Some(first_part) = path.split('.').next() { - if let Some(local_value) = context.local_vars.get(first_part) { - if first_part.len() == path.len() { - return Some(local_value.clone()); - } - let remaining = &path[first_part.len() + 1..]; - if let Some(v) = find_value_by_dotted_path(remaining, local_value) { - return Some(v); - } - } - } - // Fall back to global state - find_value_by_dotted_path(path, context.state) + fn resolve_value(&self, path: &str, context: &WebUIProcessContext<'_>) -> Option { + resolve_value_from_sources(path, &context.local_vars, context.state).map(Cow::into_owned) } /// Evaluate a condition expression against the current context. @@ -662,18 +766,7 @@ impl WebUIHandler { let local_vars = &context.local_vars; let state = context.state; match evaluate_with_resolver(condition, |path| { - if let Some(first_part) = path.split('.').next() { - if let Some(local_value) = local_vars.get(first_part) { - if first_part.len() == path.len() { - return Some(local_value.clone()); - } - let remaining = &path[first_part.len() + 1..]; - if let Some(v) = find_value_by_dotted_path(remaining, local_value) { - return Some(v); - } - } - } - find_value_by_dotted_path(path, state) + resolve_value_from_sources(path, local_vars, state) }) { Ok(result) => Ok(result), Err(ExpressionError::MissingValue(_)) => Ok(false), @@ -721,8 +814,8 @@ impl WebUIHandler { let saved_value = context.local_vars.insert(item_name.clone(), item); self.process_fragment_id(&for_loop.fragment_id, context)?; match saved_value { - Some(v) => { - context.local_vars.insert(item_name.clone(), v); + Some(value) => { + context.local_vars.insert(item_name.clone(), value); } None => { context.local_vars.remove(item_name.as_str()); @@ -818,11 +911,10 @@ impl WebUIHandler { let comp_index = crate::route_handler::build_component_index(context.protocol); // Compute the inventory hex from actually rendered components. - let (_, inventory_hex) = crate::route_handler::filter_needed_components( + let inventory_hex = crate::route_handler::encode_component_inventory( &context.rendered_components, - "", &comp_index, - )?; + ); // Emit templates for all REACHABLE components on the current route, // not just those rendered in this SSR pass. Components inside false @@ -831,15 +923,12 @@ impl WebUIHandler { // round-trip. The graph walker follows conditional and loop branches // unconditionally, but only descends into the matched route chain — // components on other routes are delivered via SPA partial navigation. - let (reachable_names, _) = crate::route_handler::get_needed_components_for_request( + let reachable = crate::route_handler::collect_reachable_components_for_request( context.protocol, &context.entry_id, &context.request_path, - "", - &comp_index, - )?; - let reachable: std::collections::HashSet = - reachable_names.into_iter().collect(); + &mut context.route_cache, + ); // Emit CSS module definitions for reachable-but-unrendered components. // Rendered components already got their