Skip to main content

a3s_flow/nodes/
template_transform.rs

1//! Built-in `"template-transform"` node — renders a Jinja2 template using
2//! upstream node outputs and global variables as context.
3//!
4//! Mirrors Dify's Template node.
5//!
6//! # Config schema
7//!
8//! ```json
9//! {
10//!   "template": "Hello {{ user.name }}! Your order #{{ order_id }} is {{ fetch.body.status }}."
11//! }
12//! ```
13//!
14//! The template context contains:
15//! - All global flow `variables` (accessible by key)
16//! - All upstream node outputs (accessible by node ID)
17//!
18//! Upstream inputs take precedence over variables with the same key.
19//!
20//! Jinja2 syntax is supported via [minijinja](https://github.com/mitsuhiko/minijinja):
21//! `{{ expr }}`, `{% if %}`, `{% for %}`, filters, etc.
22//!
23//! # Output schema
24//!
25//! ```json
26//! { "output": "Hello Alice! Your order #42 is shipped." }
27//! ```
28
29use async_trait::async_trait;
30use serde_json::{json, Value};
31
32use crate::error::{FlowError, Result};
33use crate::node::{ExecContext, Node};
34
35/// Template rendering node (Dify-compatible).
36pub struct TemplateTransformNode;
37
38#[async_trait]
39impl Node for TemplateTransformNode {
40    fn node_type(&self) -> &str {
41        "template-transform"
42    }
43
44    async fn execute(&self, ctx: ExecContext) -> Result<Value> {
45        let template_str = ctx.data["template"].as_str().ok_or_else(|| {
46            FlowError::InvalidDefinition("template-transform: missing data.template".into())
47        })?;
48
49        // Build context: variables first, then inputs (inputs shadow variables).
50        let mut context: std::collections::HashMap<String, Value> =
51            ctx.variables.into_iter().collect();
52        for (k, v) in ctx.inputs {
53            context.insert(k, v);
54        }
55
56        let env = minijinja::Environment::new();
57        let rendered = env
58            .render_str(template_str, &context)
59            .map_err(|e| FlowError::Internal(format!("template-transform: {e}")))?;
60
61        Ok(json!({ "output": rendered }))
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use std::collections::HashMap;
69
70    fn ctx(
71        inputs: HashMap<String, Value>,
72        variables: HashMap<String, Value>,
73        template: &str,
74    ) -> ExecContext {
75        ExecContext {
76            data: json!({ "template": template }),
77            inputs,
78            variables,
79            ..Default::default()
80        }
81    }
82
83    #[tokio::test]
84    async fn renders_simple_variable() {
85        let node = TemplateTransformNode;
86        let out = node
87            .execute(ctx(
88                HashMap::from([("user".into(), json!({ "name": "Alice" }))]),
89                HashMap::new(),
90                "Hello {{ user.name }}!",
91            ))
92            .await
93            .unwrap();
94        assert_eq!(out["output"], "Hello Alice!");
95    }
96
97    #[tokio::test]
98    async fn global_variables_available() {
99        let node = TemplateTransformNode;
100        let out = node
101            .execute(ctx(
102                HashMap::new(),
103                HashMap::from([("env".into(), json!("production"))]),
104                "env={{ env }}",
105            ))
106            .await
107            .unwrap();
108        assert_eq!(out["output"], "env=production");
109    }
110
111    #[tokio::test]
112    async fn inputs_shadow_variables() {
113        let node = TemplateTransformNode;
114        let out = node
115            .execute(ctx(
116                HashMap::from([("key".into(), json!("from_input"))]),
117                HashMap::from([("key".into(), json!("from_var"))]),
118                "{{ key }}",
119            ))
120            .await
121            .unwrap();
122        assert_eq!(out["output"], "from_input");
123    }
124
125    #[tokio::test]
126    async fn jinja2_if_block() {
127        let node = TemplateTransformNode;
128        let out = node
129            .execute(ctx(
130                HashMap::from([("fetch".into(), json!({ "ok": true }))]),
131                HashMap::new(),
132                "{% if fetch.ok %}ok{% else %}fail{% endif %}",
133            ))
134            .await
135            .unwrap();
136        assert_eq!(out["output"], "ok");
137    }
138
139    #[tokio::test]
140    async fn rejects_missing_template() {
141        let node = TemplateTransformNode;
142        let err = node
143            .execute(ExecContext {
144                data: json!({}),
145                inputs: HashMap::new(),
146                variables: HashMap::new(),
147                ..Default::default()
148            })
149            .await
150            .unwrap_err();
151        assert!(matches!(err, FlowError::InvalidDefinition(_)));
152    }
153}