Skip to content

Commit

Permalink
Introduce the Record trait enabling path substitution
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
didier-wenzek committed Oct 25, 2024
1 parent 1540aad commit b0f62f6
Show file tree
Hide file tree
Showing 7 changed files with 97 additions and 83 deletions.
1 change: 1 addition & 0 deletions crates/core/tedge_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod path;
pub mod script;
mod software;
mod store;
pub mod substitution;
pub mod workflow;

pub use commands::CommandStatus;
Expand Down
15 changes: 15 additions & 0 deletions crates/core/tedge_api/src/script.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::substitution::Record;
use serde::de::Error;
use serde::Deserialize;
use serde::Deserializer;
Expand All @@ -14,6 +15,20 @@ pub struct ShellScript {
pub args: Vec<String>,
}

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<D>(deserializer: D) -> Result<Self, D::Error>
Expand Down
65 changes: 65 additions & 0 deletions crates/core/tedge_api/src/substitution.rs
Original file line number Diff line number Diff line change
@@ -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<Value>;

/// 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<String> {
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(),
}
}
24 changes: 8 additions & 16 deletions crates/core/tedge_api/src/workflow/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
69 changes: 6 additions & 63 deletions crates/core/tedge_api/src/workflow/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> {
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<Value> {
fn extract_value(&self, path: &str) -> Option<Value> {
match path {
"." => Some(json!({
"topic": self.topic.name,
Expand All @@ -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
Expand Down Expand Up @@ -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<Value>")]
Expand Down
1 change: 1 addition & 0 deletions crates/core/tedge_api/src/workflow/toml_config.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
5 changes: 1 addition & 4 deletions crates/extensions/c8y_mapper_ext/src/converter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit b0f62f6

Please sign in to comment.