Skip to main content

dataflow_rs/engine/functions/
template.rs

1//! # Template
2//!
3//! A config field whose authored JSON is a JSONLogic expression, for custom
4//! handlers. This is the same shape the three built-in integration configs use
5//! for their `*_logic` fields (`path_logic`, `body_logic`, `key_logic`,
6//! `value_logic`) — `Template` makes that pattern available to any
7//! [`crate::AsyncFunctionHandler`] without hand-rolling the raw/compiled pair.
8
9use crate::engine::error::{DataflowError, Result};
10use crate::engine::task_context::TaskContext;
11use datalogic_rs::Logic;
12use datavalue::OwnedDataValue;
13use serde::{Deserialize, Deserializer};
14use serde_json::Value;
15use std::sync::Arc;
16
17/// A config field whose authored JSON is a JSONLogic expression.
18///
19/// Deserializes from any JSON value and keeps it verbatim; the expression is
20/// compiled once at engine construction (see [`crate::AsyncFunctionHandler::compile_input`])
21/// and evaluated per message on the worker thread's pooled arena.
22///
23/// Declare this type only on fields the workflow author is told are JSONLogic —
24/// the `*_logic` convention this crate's own built-ins use. Do **not** use it for
25/// freeform config values: `LogicCompiler` builds its datalogic engine with
26/// templating enabled, so a single-key object whose key happens to match an
27/// operator name (`{"cat": ["a", "b"]}`) evaluates as that operator rather than
28/// being returned as a literal object. That is existing behaviour for the
29/// built-in `*_logic` fields, not a `Template`-specific regression — it is the
30/// reason this type is opt-in per field rather than a blanket JSON wrapper.
31#[derive(Debug, Clone)]
32pub struct Template {
33    raw: Value,
34    compiled: Option<Arc<Logic>>,
35}
36
37// Hand-written rather than `#[serde(from = "Value")]` plus `impl From<Value>`:
38// a container-level `from` builds the target solely through `From`, so a
39// field-level `#[serde(skip)]` on `compiled` would be inert and misleading next
40// to a manual `From` impl anyway. This is the same five lines, explicit about
41// which path runs.
42impl<'de> Deserialize<'de> for Template {
43    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
44        Ok(Self {
45            raw: Value::deserialize(d)?,
46            compiled: None,
47        })
48    }
49}
50
51impl Template {
52    /// Compile the expression. Called once at engine construction via
53    /// [`crate::AsyncFunctionHandler::compile_input`]. `label` is used only in
54    /// the error message, matching `LogicCompiler`'s
55    /// `"<what> for task <id> in workflow <id>"` convention.
56    ///
57    /// # Errors
58    ///
59    /// [`DataflowError::LogicEvaluation`] if the expression fails to compile,
60    /// with `label` prefixed onto the message.
61    pub fn compile(&mut self, c: &TemplateCompiler, label: &str) -> Result<()> {
62        let compiled = c
63            .engine
64            .compile_arc(&self.raw)
65            .map_err(|e| DataflowError::LogicEvaluation(format!("{label}: {e}")))?;
66        self.compiled = Some(compiled);
67        Ok(())
68    }
69
70    /// Evaluate against the message context, on the worker thread's pooled bump
71    /// arena.
72    ///
73    /// # Errors
74    ///
75    /// [`DataflowError::LogicEvaluation`] if [`Self::compile`] was never called —
76    /// naming the field is the caller's job via `label`, since this type has no
77    /// field name of its own to report — or if evaluation itself fails.
78    pub fn eval(&self, ctx: &TaskContext<'_>) -> Result<OwnedDataValue> {
79        let logic = self.compiled.as_deref().ok_or_else(|| {
80            DataflowError::LogicEvaluation(
81                "Template::eval called before Template::compile — the engine did not compile \
82                 this field at construction time"
83                    .to_string(),
84            )
85        })?;
86        ctx.eval(logic)
87    }
88
89    /// As [`Self::eval`], deserialized into `T`.
90    ///
91    /// Routes through `serde_json::Value` — [`TaskContext::eval_json`] then
92    /// `serde_json::from_value` — so it costs one extra walk and rebuild past
93    /// [`Self::eval`]. Prefer `eval` when `T` is `OwnedDataValue` or when you
94    /// only need to inspect the result, not deserialize it into a caller type.
95    ///
96    /// # Errors
97    ///
98    /// As [`Self::eval`], plus a deserialization error if the evaluated JSON does
99    /// not fit `T`.
100    pub fn eval_into<T: serde::de::DeserializeOwned>(&self, ctx: &TaskContext<'_>) -> Result<T> {
101        let logic = self.compiled.as_deref().ok_or_else(|| {
102            DataflowError::LogicEvaluation(
103                "Template::eval_into called before Template::compile — the engine did not \
104                 compile this field at construction time"
105                    .to_string(),
106            )
107        })?;
108        let json = ctx.eval_json(logic)?;
109        serde_json::from_value(json).map_err(DataflowError::from_serde)
110    }
111
112    /// As [`Self::eval`], coerced to a *plain* string via
113    /// [`TaskContext::eval_to_plain_string`] — a JSON string result yields its
114    /// contents, anything else its compact JSON form. Use this when the result
115    /// is going into a URL path or a message key, where JSON quoting would be
116    /// wrong.
117    ///
118    /// # Errors
119    ///
120    /// As [`Self::eval`].
121    pub fn eval_to_plain_string(&self, ctx: &TaskContext<'_>) -> Result<String> {
122        let logic = self.compiled.as_deref().ok_or_else(|| {
123            DataflowError::LogicEvaluation(
124                "Template::eval_to_plain_string called before Template::compile — the engine \
125                 did not compile this field at construction time"
126                    .to_string(),
127            )
128        })?;
129        ctx.eval_to_plain_string(logic)
130    }
131
132    /// The authored JSON, unchanged. For handlers that need to report or
133    /// re-serialize their own config.
134    pub fn as_json(&self) -> &Value {
135        &self.raw
136    }
137
138    /// Whether [`Self::compile`] has run. Mainly for tests and for callers that
139    /// want to assert the build pass reached them.
140    pub fn is_compiled(&self) -> bool {
141        self.compiled.is_some()
142    }
143}
144
145/// Handed to [`crate::AsyncFunctionHandler::compile_input`] to compile a
146/// handler's `Template` fields at engine construction.
147///
148/// Wraps the same `Arc<datalogic_rs::Engine>` `LogicCompiler` uses internally,
149/// so a compiled `Template` is evaluable by the engine that will run the
150/// message. A newtype rather than a bare `Arc<datalogic_rs::Engine>` so fields
151/// can be added later without changing `compile_input`'s signature.
152pub struct TemplateCompiler {
153    engine: Arc<datalogic_rs::Engine>,
154}
155
156impl TemplateCompiler {
157    pub(crate) fn new(engine: Arc<datalogic_rs::Engine>) -> Self {
158        Self { engine }
159    }
160
161    /// The shared datalogic engine, for handlers that need to compile something
162    /// other than a `Template` field directly.
163    pub fn engine(&self) -> &datalogic_rs::Engine {
164        &self.engine
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::engine::message::Message;
172    use serde_json::json;
173
174    fn engine() -> Arc<datalogic_rs::Engine> {
175        Arc::new(
176            datalogic_rs::Engine::builder()
177                .with_templating(true)
178                .build(),
179        )
180    }
181
182    fn template_from(v: Value) -> Template {
183        serde_json::from_value(v).unwrap()
184    }
185
186    #[test]
187    fn deserializes_from_every_json_shape_and_as_json_is_verbatim() {
188        for v in [
189            json!({"a": 1}),
190            json!([1, 2, 3]),
191            json!("hello"),
192            json!(42),
193            json!(true),
194            json!(null),
195            json!({}),
196        ] {
197            let t = template_from(v.clone());
198            assert_eq!(t.as_json(), &v);
199            assert!(!t.is_compiled());
200        }
201    }
202
203    #[test]
204    fn eval_before_compile_errors_without_panicking() {
205        let dl = engine();
206        let mut m = Message::from_value(&json!({}));
207        let ctx = TaskContext::new(&mut m, &dl);
208        let t = template_from(json!({"var": "data.x"}));
209
210        match t.eval(&ctx) {
211            Err(DataflowError::LogicEvaluation(msg)) => {
212                assert!(
213                    msg.contains("compile"),
214                    "message should name the cause: {msg}"
215                );
216            }
217            other => panic!("expected LogicEvaluation, got {other:?}"),
218        }
219    }
220
221    #[test]
222    fn compile_on_a_malformed_expression_names_the_label() {
223        // datalogic-rs's templating mode is deliberately permissive at compile
224        // time: an unrecognised operator key compiles as a literal (or, at the
225        // top level, a structured-object template) rather than erroring — this
226        // is existing engine behaviour, not something `Template` controls, and
227        // it is why a static "known operators" table would mislead (see #26's
228        // scope notes). The one thing that reliably fails to *compile* — as
229        // opposed to failing at *evaluation* — is rule nesting past the
230        // engine's `MAX_COMPILE_DEPTH` (256), verified directly against
231        // datalogic-rs 5.1.1 before writing this test.
232        let c = TemplateCompiler::new(engine());
233        let mut too_deep = json!(1);
234        for _ in 0..300 {
235            too_deep = json!({"var": too_deep});
236        }
237        let mut t = template_from(too_deep);
238
239        match t.compile(&c, "my_field for task t in workflow w") {
240            Err(DataflowError::LogicEvaluation(msg)) => {
241                assert!(
242                    msg.contains("my_field for task t in workflow w"),
243                    "got: {msg}"
244                );
245            }
246            other => panic!("expected LogicEvaluation, got {other:?}"),
247        }
248    }
249
250    #[test]
251    fn a_literal_template_evaluates_to_that_literal() {
252        let dl = engine();
253        let c = TemplateCompiler::new(Arc::clone(&dl));
254        let mut m = Message::from_value(&json!({}));
255        let ctx = TaskContext::new(&mut m, &dl);
256
257        for v in [
258            json!("hello"),
259            json!(42),
260            json!({}),
261            json!({"a": 1, "b": 2}),
262        ] {
263            let mut t = template_from(v.clone());
264            t.compile(&c, "lbl").unwrap();
265            assert_eq!(t.eval_into::<Value>(&ctx).unwrap(), v);
266        }
267    }
268
269    #[test]
270    fn a_single_key_operator_name_evaluates_as_the_operator() {
271        // Pinned deliberately: this is why Template is opt-in per field, not a
272        // blanket wrapper. LogicCompiler enables templating, and this crate's
273        // TaskContext-backed evaluation goes through the same engine.
274        let dl = engine();
275        let c = TemplateCompiler::new(Arc::clone(&dl));
276        let mut m = Message::from_value(&json!({}));
277        let ctx = TaskContext::new(&mut m, &dl);
278
279        let mut t = template_from(json!({"cat": ["a", "b"]}));
280        t.compile(&c, "lbl").unwrap();
281        assert_eq!(t.eval_into::<Value>(&ctx).unwrap(), json!("ab"));
282    }
283
284    #[test]
285    fn eval_to_plain_string_unquotes_and_coerces_non_strings() {
286        let dl = engine();
287        let c = TemplateCompiler::new(Arc::clone(&dl));
288        let mut m = Message::from_value(&json!({}));
289        let ctx = TaskContext::new(&mut m, &dl);
290
291        let mut string_t = template_from(json!("abc"));
292        string_t.compile(&c, "lbl").unwrap();
293        assert_eq!(string_t.eval_to_plain_string(&ctx).unwrap(), "abc");
294
295        let mut num_t = template_from(json!(7));
296        num_t.compile(&c, "lbl").unwrap();
297        assert_eq!(num_t.eval_to_plain_string(&ctx).unwrap(), "7");
298
299        let mut obj_t = template_from(json!({"a": 1}));
300        obj_t.compile(&c, "lbl").unwrap();
301        assert_eq!(obj_t.eval_to_plain_string(&ctx).unwrap(), "{\"a\":1}");
302    }
303
304    #[test]
305    fn eval_to_plain_string_before_compile_errors_without_panicking() {
306        let mut m = Message::from_value(&json!({}));
307        let dl = engine();
308        let ctx = TaskContext::new(&mut m, &dl);
309        let t = template_from(json!("abc"));
310
311        match t.eval_to_plain_string(&ctx) {
312            Err(DataflowError::LogicEvaluation(msg)) => {
313                assert!(
314                    msg.contains("compile"),
315                    "message should name the cause: {msg}"
316                );
317            }
318            other => panic!("expected LogicEvaluation, got {other:?}"),
319        }
320    }
321
322    #[test]
323    fn non_ascii_result_round_trips() {
324        let dl = engine();
325        let c = TemplateCompiler::new(Arc::clone(&dl));
326        let mut m = Message::from_value(&json!({}));
327        let ctx = TaskContext::new(&mut m, &dl);
328
329        let mut t = template_from(json!({"cat": ["über-", "größe"]}));
330        t.compile(&c, "lbl").unwrap();
331        assert_eq!(t.eval_into::<String>(&ctx).unwrap(), "über-größe");
332    }
333
334    #[test]
335    fn reading_an_absent_path_matches_the_engines_missing_path_result() {
336        let dl = engine();
337        let c = TemplateCompiler::new(Arc::clone(&dl));
338        let mut m = Message::from_value(&json!({}));
339        let ctx = TaskContext::new(&mut m, &dl);
340
341        let mut t = template_from(json!({"var": "data.nope"}));
342        t.compile(&c, "lbl").unwrap();
343        // Not an error — the same "resolves to Null" behaviour as the built-in
344        // *_logic fields on a missing path.
345        assert_eq!(t.eval(&ctx).unwrap(), OwnedDataValue::Null);
346    }
347}