use ::serde::{Deserialize, Serialize};
use async_trait::async_trait;
use handlebars::{Handlebars, JsonValue};
use schemars::{JsonSchema, Schema, schema_for};
use serde_json::json;
use crate::{
message::Message,
node::{NodeContext, NodeErr, NodeError, NodeOut, NodeType, Routing},
};
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(transparent)]
pub struct TemplateProcessNode {
pub template: String,
}
#[async_trait]
#[typetag::serde]
impl NodeType for TemplateProcessNode {
fn type_name(&self) -> String {
"template".to_string()
}
fn schema(&self) -> Schema {
schema_for!(TemplateProcessNode)
}
#[tracing::instrument(name = "template_node_process", skip(self, context))]
async fn process(&self, input: Message, context: &mut NodeContext) -> Result<NodeOut, NodeErr> {
let hbs = Handlebars::new();
let mut data = serde_json::Map::new();
data.insert(
"msg".to_string(),
serde_json::to_value(&input).unwrap_or(json!({})),
);
let state_map: serde_json::Map<String, JsonValue> = context
.get_all_state()
.into_iter()
.map(|(k, v)| (k, v.to_json()))
.collect();
data.insert("state".to_string(), JsonValue::Object(state_map));
let payload_value = match &input.payload() {
serde_json::Value::String(s) => {
serde_json::from_str::<JsonValue>(s).unwrap_or(json!({}))
}
other => other.clone(),
};
data.insert("payload".to_string(), payload_value);
let rendered = hbs.render_template(&self.template, &data).map_err(|e| {
NodeErr::fail(NodeError::InvalidInput(format!(
"Template render error: {}",
e
)))
})?;
let msg = Message::new(&input.id(), json!({"text": rendered}), input.session_id());
Ok(NodeOut::with_routing(msg, Routing::FollowGraph))
}
fn clone_box(&self) -> Box<dyn NodeType> {
Box::new(self.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::flow::state::StateValue;
use crate::message::Message;
use crate::node::NodeContext;
use serde_json::json;
fn dummy_context_with_state() -> NodeContext {
let mut ctx = NodeContext::dummy();
ctx.set_state("age", StateValue::try_from(json!(30)).unwrap());
ctx.set_state(
"user",
StateValue::try_from(json!({"name": "Alice"})).unwrap(),
);
ctx
}
#[tokio::test]
async fn test_basic_template_rendering() {
let node = TemplateProcessNode {
template: "Hello world!".to_string(),
};
let msg = Message::new("test_id", json!({}), "123".to_string());
let mut ctx = NodeContext::dummy();
let output = node.process(msg.clone(), &mut ctx).await.unwrap();
let payload = output.message().payload();
let text = payload["text"].as_str().unwrap();
assert_eq!(text, "Hello world!");
}
#[tokio::test]
async fn test_template_with_msg_state_payload() {
let node = TemplateProcessNode {
template: "Hi {{msg.id}}, you are {{state.age}} and it's {{payload.weather.temp}}°C."
.to_string(),
};
let msg = Message::new(
"abc123",
json!({"weather": {"temp": 21}}),
"sess42".to_string(),
);
let mut ctx = dummy_context_with_state();
let output = node.process(msg.clone(), &mut ctx).await.unwrap();
let payload = output.message().payload();
let text = payload["text"].as_str().unwrap();
assert_eq!(text, "Hi abc123, you are 30 and it's 21°C.");
}
#[tokio::test]
async fn test_stringified_json_payload() {
let node = TemplateProcessNode {
template: "Temperature: {{payload.weather.temp}}".to_string(),
};
let json_string = r#"{"weather": {"temp": 17}}"#;
let msg = Message::new("abc123", json_string.into(), "123".to_string());
let mut ctx = NodeContext::dummy();
let output = node.process(msg.clone(), &mut ctx).await.unwrap();
let payload = output.message().payload();
let text = payload["text"].as_str().unwrap();
assert_eq!(text, "Temperature: 17");
}
#[tokio::test]
async fn test_missing_template_fields_gracefully() {
let node = TemplateProcessNode {
template: "Hello {{state.name}}, temp: {{payload.temp}}".to_string(),
};
let msg = Message::new("id", json!({}), "123".to_string());
let mut ctx = NodeContext::dummy();
let output = node.process(msg.clone(), &mut ctx).await.unwrap();
let payload = output.message().payload();
let text = payload["text"].as_str().unwrap();
assert_eq!(text, "Hello , temp: ");
}
}