From b0f62f640e1ec17b64a4e5fd22cb7295493ceed9 Mon Sep 17 00:00:00 2001 From: Didier Wenzek Date: Thu, 24 Oct 2024 17:17:18 +0200 Subject: [PATCH] Introduce the Record trait enabling path substitution The `inject_values_into_template` method has been extracted into a `trait Record` making the feature usable not only with GenericCommandState (a workflow specific type) but with any types implementing the `Record` trait. i.e. the ability to extract a json value given a path. Signed-off-by: Didier Wenzek --- crates/core/tedge_api/src/lib.rs | 1 + crates/core/tedge_api/src/script.rs | 15 ++++ crates/core/tedge_api/src/substitution.rs | 65 +++++++++++++++++ crates/core/tedge_api/src/workflow/mod.rs | 24 +++---- crates/core/tedge_api/src/workflow/state.rs | 69 ++----------------- .../tedge_api/src/workflow/toml_config.rs | 1 + .../c8y_mapper_ext/src/converter.rs | 5 +- 7 files changed, 97 insertions(+), 83 deletions(-) create mode 100644 crates/core/tedge_api/src/substitution.rs diff --git a/crates/core/tedge_api/src/lib.rs b/crates/core/tedge_api/src/lib.rs index 16d1672501..21be56718b 100644 --- a/crates/core/tedge_api/src/lib.rs +++ b/crates/core/tedge_api/src/lib.rs @@ -11,6 +11,7 @@ pub mod path; pub mod script; mod software; mod store; +pub mod substitution; pub mod workflow; pub use commands::CommandStatus; diff --git a/crates/core/tedge_api/src/script.rs b/crates/core/tedge_api/src/script.rs index dff5eeea48..7251a8b716 100644 --- a/crates/core/tedge_api/src/script.rs +++ b/crates/core/tedge_api/src/script.rs @@ -1,3 +1,4 @@ +use crate::substitution::Record; use serde::de::Error; use serde::Deserialize; use serde::Deserializer; @@ -14,6 +15,20 @@ pub struct ShellScript { pub args: Vec, } +impl ShellScript { + /// Inject values extracted from the record into a script command and arguments. + /// + /// - The script command is first tokenized using shell escaping rules. + /// `/some/script.sh arg1 "arg 2" "arg 3"` -> ["/some/script.sh", "arg1", "arg 2", "arg 3"] + /// - Then each token matching `${x.y.z}` is substituted with the value pointed by the JSON path in the record. + pub fn inject_values(&self, record: &impl Record) -> ShellScript { + ShellScript { + command: record.inject_values_into_template(&self.command), + args: record.inject_values_into_templates(&self.args), + } + } +} + /// Deserialize an Unix command line impl<'de> Deserialize<'de> for ShellScript { fn deserialize(deserializer: D) -> Result diff --git a/crates/core/tedge_api/src/substitution.rs b/crates/core/tedge_api/src/substitution.rs new file mode 100644 index 0000000000..69e6a4cb30 --- /dev/null +++ b/crates/core/tedge_api/src/substitution.rs @@ -0,0 +1,65 @@ +use serde_json::Value; + +pub trait Record { + /// Extract from this record the JSON value pointed by the given path + fn extract_value(&self, path: &str) -> Option; + + /// Inject values extracted from the record into a template string + /// + /// - Search the template string for path patterns `${...}` + /// - Replace all these paths by the value extracted from self using the paths + /// + /// `"prefix-${.payload.x}-separator-${.payload.y}-suffix"` is replaced by + /// `"prefix-X-separator-Y-suffix"` in a context where the payload is `{"x":"X", "y":"Y"}` + fn inject_values_into_template(&self, target: &str) -> String { + target + .split_inclusive('}') + .flat_map(|s| match s.find("${") { + None => vec![s], + Some(i) => { + let (prefix, template) = s.split_at(i); + vec![prefix, template] + } + }) + .map(|s| self.replace_path_with_value(s)) + .collect() + } + + /// Inject values extracted from the record into a vector of target strings. + fn inject_values_into_templates(&self, targets: &[String]) -> Vec { + targets + .iter() + .map(|arg| self.inject_values_into_template(arg)) + .collect() + } + + /// Replace a path pattern with the value extracted from the message payload using that path + /// + /// `${.payload}` -> the whole message payload + /// `${.payload.x}` -> the value of x if there is any in the payload + /// `${.payload.unknown}` -> `${.payload.unknown}` unchanged + /// `Not a path expression` -> `Not a path expression` unchanged + fn replace_path_with_value(&self, template: &str) -> String { + Self::extract_path(template) + .and_then(|path| self.extract_value(path)) + .map(|v| json_as_string(&v)) + .unwrap_or_else(|| template.to_string()) + } + + /// Extract a path from a `${ ... }` expression + /// + /// Return None if the input is not a path expression + fn extract_path(input: &str) -> Option<&str> { + input.strip_prefix("${").and_then(|s| s.strip_suffix('}')) + } +} + +fn json_as_string(value: &Value) -> String { + match value { + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + _ => value.to_string(), + } +} diff --git a/crates/core/tedge_api/src/workflow/mod.rs b/crates/core/tedge_api/src/workflow/mod.rs index a635440121..810e7edd39 100644 --- a/crates/core/tedge_api/src/workflow/mod.rs +++ b/crates/core/tedge_api/src/workflow/mod.rs @@ -10,6 +10,7 @@ use crate::mqtt_topics::EntityTopicId; use crate::mqtt_topics::MqttSchema; use crate::mqtt_topics::OperationType; use crate::script::ShellScript; +use crate::substitution::Record; use ::log::info; pub use error::*; pub use handlers::*; @@ -345,19 +346,17 @@ impl OperationWorkflow { impl OperationAction { pub fn inject_state(&self, state: &GenericCommandState) -> Self { match self { - OperationAction::Script(script, handlers) => OperationAction::Script( - Self::inject_values_into_script(state, script), - handlers.clone(), - ), - OperationAction::BgScript(script, handlers) => OperationAction::BgScript( - Self::inject_values_into_script(state, script), - handlers.clone(), - ), + OperationAction::Script(script, handlers) => { + OperationAction::Script(script.inject_values(state), handlers.clone()) + } + OperationAction::BgScript(script, handlers) => { + OperationAction::BgScript(script.inject_values(state), handlers.clone()) + } OperationAction::Operation(operation_expr, optional_script, input, handlers) => { let operation = state.inject_values_into_template(operation_expr); let optional_script = optional_script .as_ref() - .map(|script| Self::inject_values_into_script(state, script)); + .map(|script| script.inject_values(state)); OperationAction::Operation( operation, optional_script, @@ -369,13 +368,6 @@ impl OperationAction { } } - fn inject_values_into_script(state: &GenericCommandState, script: &ShellScript) -> ShellScript { - ShellScript { - command: state.inject_values_into_template(&script.command), - args: state.inject_values_into_parameters(&script.args), - } - } - pub fn process_iterate( state: GenericCommandState, json_path: &str, diff --git a/crates/core/tedge_api/src/workflow/state.rs b/crates/core/tedge_api/src/workflow/state.rs index ed12c54862..fd103adc27 100644 --- a/crates/core/tedge_api/src/workflow/state.rs +++ b/crates/core/tedge_api/src/workflow/state.rs @@ -2,6 +2,7 @@ use crate::mqtt_topics::Channel; use crate::mqtt_topics::EntityTopicId; use crate::mqtt_topics::MqttSchema; use crate::mqtt_topics::OperationType; +use crate::substitution::Record; use crate::workflow::CommandId; use crate::workflow::ExitHandlers; use crate::workflow::OperationName; @@ -284,65 +285,15 @@ impl GenericCommandState { o.insert(property.to_string(), value.into()); } } +} - /// Inject values extracted from the message payload into a script command line. - /// - /// - The script command is first tokenized using shell escaping rules. - /// `/some/script.sh arg1 "arg 2" "arg 3"` -> ["/some/script.sh", "arg1", "arg 2", "arg 3"] - /// - Then each token matching `${x.y.z}` is substituted with the value pointed by the JSON path. - pub fn inject_values_into_parameters(&self, args: &[String]) -> Vec { - args.iter() - .map(|arg| self.inject_values_into_template(arg)) - .collect() - } - - /// Inject values extracted from the message payload into a template string - /// - /// - Search the template string for path patterns `${...}` - /// - Replace all these paths by the value extracted from self using the paths - /// - /// `"prefix-${.payload.x}-separator-${.payload.y}-suffix"` is replaced by - /// `"prefix-X-separator-Y-suffix"` in a context where the payload is `{"x":"X", "y":"Y"}` - pub fn inject_values_into_template(&self, target: &str) -> String { - target - .split_inclusive('}') - .flat_map(|s| match s.find("${") { - None => vec![s], - Some(i) => { - let (prefix, template) = s.split_at(i); - vec![prefix, template] - } - }) - .map(|s| self.replace_path_with_value(s)) - .collect() - } - - /// Replace a path pattern with the value extracted from the message payload using that path - /// - /// `${.payload}` -> the whole message payload - /// `${.payload.x}` -> the value of x if there is any in the payload - /// `${.payload.unknown}` -> `${.payload.unknown}` unchanged - /// `Not a path expression` -> `Not a path expression` unchanged - fn replace_path_with_value(&self, template: &str) -> String { - Self::extract_path(template) - .and_then(|path| self.extract_value(path)) - .map(|v| json_as_string(&v)) - .unwrap_or_else(|| template.to_string()) - } - - /// Extract a path from a `${ ... }` expression - /// - /// Return None if the input is not a path expression - pub fn extract_path(input: &str) -> Option<&str> { - input.strip_prefix("${").and_then(|s| s.strip_suffix('}')) - } - +impl Record for GenericCommandState { /// Extract the JSON value pointed by a path from this command state /// /// Return None if the path contains unknown fields, /// with the exception that the empty string is returned for an unknown path below the `.payload`, /// the rational being that the payload object represents a free-form value. - pub fn extract_value(&self, path: &str) -> Option { + fn extract_value(&self, path: &str) -> Option { match path { "." => Some(json!({ "topic": self.topic.name, @@ -364,7 +315,9 @@ impl GenericCommandState { } } } +} +impl GenericCommandState { /// Return the topic that uniquely identifies the command pub fn command_topic(&self) -> &String { &self.topic.name @@ -594,16 +547,6 @@ fn json_excerpt<'a>(value: &'a Value, path: &'a str) -> Option<&'a Value> { } } -fn json_as_string(value: &Value) -> String { - match value { - Value::Null => "null".to_string(), - Value::Bool(b) => b.to_string(), - Value::Number(n) => n.to_string(), - Value::String(s) => s.clone(), - _ => value.to_string(), - } -} - /// A set of values to be injected/extracted into/from a [GenericCommandState] #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(try_from = "Option")] diff --git a/crates/core/tedge_api/src/workflow/toml_config.rs b/crates/core/tedge_api/src/workflow/toml_config.rs index df648eb1b8..9c1aecbdba 100644 --- a/crates/core/tedge_api/src/workflow/toml_config.rs +++ b/crates/core/tedge_api/src/workflow/toml_config.rs @@ -1,5 +1,6 @@ use crate::mqtt_topics::OperationType; use crate::script::ShellScript; +use crate::substitution::Record; use crate::workflow::AwaitHandlers; use crate::workflow::DefaultHandlers; use crate::workflow::ExecHandlers; diff --git a/crates/extensions/c8y_mapper_ext/src/converter.rs b/crates/extensions/c8y_mapper_ext/src/converter.rs index 41d7421181..528eaa1328 100644 --- a/crates/extensions/c8y_mapper_ext/src/converter.rs +++ b/crates/extensions/c8y_mapper_ext/src/converter.rs @@ -764,10 +764,7 @@ impl CumulocityConverter { err_msg: format!("Fail to parse the script {command_value}: {e}"), } })?; - let script = ShellScript { - command: state.inject_values_into_template(&script_template.command), - args: state.inject_values_into_parameters(&script_template.args), - }; + let script = script_template.inject_values(&state); self.execute_operation( script,