Skip to main content

dataflow_rs/engine/functions/
publish.rs

1//! # Publish Function Module
2//!
3//! Serialises a slice of the message's `data` context to a JSON or XML string
4//! and stores it back under `data.{target}`. JSON uses `OwnedDataValue`'s
5//! native `to_json_string`; pretty-printed JSON and XML both bridge through
6//! `serde_json::Value` since neither is on the hot path.
7
8use crate::engine::error::{DataflowError, Result};
9use crate::engine::executor::{ArenaContext, with_arena};
10use crate::engine::functions::path_template::{DataRoot, ParamCtx, PathTemplate, ResolvedPath};
11use crate::engine::functions::template::Template;
12use crate::engine::message::{Change, Message};
13use crate::engine::task_outcome::TaskOutcome;
14use crate::engine::utils::{get_nested_value, get_nested_value_parts, set_nested_value_parts};
15use datalogic_rs::Engine;
16use datavalue::OwnedDataValue;
17use log::debug;
18use serde::Deserialize;
19use serde_json::Value;
20use std::borrow::Cow;
21use std::sync::Arc;
22
23/// Configuration for publish functions.
24#[derive(Debug, Clone, Deserialize)]
25pub struct PublishConfig {
26    /// Source field path inside `data` to serialize, as JSONLogic. Resolves to
27    /// the *name* of a location, not the value at one.
28    pub source: Template,
29
30    /// Target field name inside `data` to receive the serialised string, as
31    /// JSONLogic. A literal folds at compile time and keeps the precomputed
32    /// split this always had.
33    pub target: PathTemplate<DataRoot>,
34
35    /// Whether to pretty-print the output (JSON only).
36    #[serde(default)]
37    pub pretty: bool,
38
39    /// Root element name for XML output, as JSONLogic — so the element can
40    /// follow the message type: `{"var": "data.doc_type"}`.
41    #[serde(default = "default_root_element")]
42    pub root_element: Template,
43}
44
45// Manual impl so `..Default::default()` construction gets the same
46// `root_element` the serde default supplies ("root", not "").
47impl Default for PublishConfig {
48    fn default() -> Self {
49        Self {
50            source: Template::from(Value::String(String::new())),
51            target: PathTemplate::default(),
52            pretty: false,
53            root_element: default_root_element(),
54        }
55    }
56}
57
58fn default_root_element() -> Template {
59    Template::from(Value::from(DEFAULT_ROOT_ELEMENT))
60}
61
62/// The `root_element` default, as a plain name.
63pub const DEFAULT_ROOT_ELEMENT: &str = "root";
64
65impl PublishConfig {
66    pub fn from_json(input: &Value) -> Result<Self> {
67        let source = input.get("source").cloned().ok_or_else(|| {
68            DataflowError::Validation("Missing 'source' in publish config".to_string())
69        })?;
70
71        let target = input.get("target").cloned().ok_or_else(|| {
72            DataflowError::Validation("Missing 'target' in publish config".to_string())
73        })?;
74
75        let pretty = input
76            .get("pretty")
77            .and_then(Value::as_bool)
78            .unwrap_or(false);
79
80        let root_element = input
81            .get("root_element")
82            .cloned()
83            .map(Template::from)
84            .unwrap_or_else(default_root_element);
85
86        Ok(Self {
87            source: Template::from(source),
88            target: PathTemplate::from(target),
89            pretty,
90            root_element,
91        })
92    }
93
94    /// The write destination for this message, as `(dotted, parts)`.
95    ///
96    /// # Errors
97    ///
98    /// [`DataflowError::LogicEvaluation`] if the expression fails to evaluate.
99    pub(crate) fn resolve_target_path(&self, p: ParamCtx<'_>) -> Result<Cow<'_, ResolvedPath>> {
100        self.target.resolve_in_arena(p)
101    }
102
103    /// Resolve the source value as a borrow into the message context. The
104    /// serializers below only read the value, so no deep clone of the source
105    /// subtree is needed — the borrow ends before the context mutation.
106    /// Returns `None` when the path doesn't resolve.
107    fn resolve_source<'m>(&self, message: &'m Message, source: &str) -> Option<&'m OwnedDataValue> {
108        // Direct field in `data` (also matches keys containing literal dots,
109        // which the nested walk below would split).
110        if let Some(value) = message.data().get(source) {
111            return Some(value);
112        }
113
114        // Nested path inside `data`.
115        if let Some(value) = get_nested_value(message.data(), source) {
116            return Some(value);
117        }
118
119        // `data.<path>` shorthand pointing back into `data`.
120        if let Some(path) = source.strip_prefix("data.") {
121            return get_nested_value(message.data(), path);
122        }
123
124        None
125    }
126}
127
128/// Shared tail behind [`execute_publish_json`] and [`execute_publish_xml`]:
129/// resolve the target path, snapshot the old value, write the already-computed
130/// `serialized` string, and build the `Change`. The two callers differ only in
131/// how they produce `serialized` (JSON vs. XML) — everything from "where does
132/// it land" onward is identical.
133fn finish_publish(
134    message: &mut Message,
135    target: &ResolvedPath,
136    serialized: String,
137) -> (TaskOutcome, Vec<Change>) {
138    let (target_path_arc, target_parts) = (&target.0, &*target.1);
139    let old_value = get_nested_value_parts(&message.context, target_parts)
140        .cloned()
141        .unwrap_or(OwnedDataValue::Null);
142    let new_value = OwnedDataValue::String(serialized);
143
144    set_nested_value_parts(&mut message.context, target_parts, new_value.clone());
145
146    (
147        TaskOutcome::Success,
148        vec![Change {
149            path: Arc::clone(target_path_arc),
150            old_value,
151            new_value,
152        }],
153    )
154}
155
156/// Execute `publish_json`: serialise `data.{source}` to a JSON string and
157/// store at `data.{target}`.
158pub fn execute_publish_json(
159    message: &mut Message,
160    config: &PublishConfig,
161    engine: &Engine,
162) -> Result<(TaskOutcome, Vec<Change>)> {
163    with_arena(|arena| {
164        let arena_ctx = ArenaContext::from_owned(&message.context, arena);
165        publish_json_in(message, config, ParamCtx::from_arena(engine, &arena_ctx))
166    })
167}
168
169pub(crate) fn publish_json_in(
170    message: &mut Message,
171    config: &PublishConfig,
172    p: ParamCtx<'_>,
173) -> Result<(TaskOutcome, Vec<Change>)> {
174    let source = config.source.resolve_str_in_arena(p)?;
175    let target = config.resolve_target_path(p)?;
176    debug!("PublishJson: Serializing 'data.{source}' to '{}'", target.0);
177
178    // Borrowed resolve — a missing path and an explicit Null both reject,
179    // matching the historical extract_source contract.
180    let source_data = match config.resolve_source(message, &source) {
181        Some(v) if !matches!(v, OwnedDataValue::Null) => v,
182        _ => {
183            return Err(DataflowError::Validation(format!(
184                "PublishJson: Source 'data.{source}' not found or is null"
185            )));
186        }
187    };
188
189    // For compact JSON, use OwnedDataValue's native emitter (fastest path).
190    // For pretty JSON, bridge to serde_json::Value — pretty publish is not a
191    // hot path and the bridge cost there is irrelevant. Either way the
192    // serializer reads through the borrow; the source subtree is never
193    // deep-cloned.
194    let json_string = if config.pretty {
195        let bridge = Value::from(source_data);
196        serde_json::to_string_pretty(&bridge)
197            .map_err(|e| DataflowError::Validation(format!("Failed to serialize to JSON: {}", e)))?
198    } else {
199        source_data.to_json_string()
200    };
201
202    Ok(finish_publish(message, &target, json_string))
203}
204
205/// Execute `publish_xml`: serialise `data.{source}` to an XML string and
206/// store at `data.{target}`. Bridges to `serde_json::Value` for the existing
207/// recursive XML walker — XML is the slow path, no perf concern.
208pub fn execute_publish_xml(
209    message: &mut Message,
210    config: &PublishConfig,
211    engine: &Engine,
212) -> Result<(TaskOutcome, Vec<Change>)> {
213    with_arena(|arena| {
214        let arena_ctx = ArenaContext::from_owned(&message.context, arena);
215        publish_xml_in(message, config, ParamCtx::from_arena(engine, &arena_ctx))
216    })
217}
218
219pub(crate) fn publish_xml_in(
220    message: &mut Message,
221    config: &PublishConfig,
222    p: ParamCtx<'_>,
223) -> Result<(TaskOutcome, Vec<Change>)> {
224    let source = config.source.resolve_str_in_arena(p)?;
225    let target = config.resolve_target_path(p)?;
226    debug!("PublishXml: Serializing 'data.{source}' to '{}'", target.0);
227
228    // Borrowed resolve — same contract as the JSON path: missing and
229    // explicit-Null sources both reject, no source deep clone.
230    let source_data = match config.resolve_source(message, &source) {
231        Some(v) if !matches!(v, OwnedDataValue::Null) => v,
232        _ => {
233            return Err(DataflowError::Validation(format!(
234                "PublishXml: Source 'data.{source}' not found or is null"
235            )));
236        }
237    };
238
239    let bridge = Value::from(source_data);
240    let root_element = config.root_element.resolve_str_in_arena(p)?;
241    let xml_string = json_to_xml(&bridge, &root_element)?;
242
243    Ok(finish_publish(message, &target, xml_string))
244}
245
246/// Convert JSON Value to XML string. Recursive walker; same shape as before
247/// the OwnedDataValue refactor — kept on `serde_json::Value` since XML is the
248/// slow path.
249fn json_to_xml(value: &Value, root_element: &str) -> Result<String> {
250    let mut buffer = String::new();
251
252    match value {
253        Value::Object(_) => {
254            buffer.push_str(&format!("<{}>", root_element));
255            let content = serialize_value_to_xml_content(value)?;
256            buffer.push_str(&content);
257            buffer.push_str(&format!("</{}>", root_element));
258        }
259        Value::Array(arr) => {
260            buffer.push_str(&format!("<{}>", root_element));
261            for item in arr {
262                buffer.push_str("<item>");
263                let content = serialize_value_to_xml_content(item)?;
264                buffer.push_str(&content);
265                buffer.push_str("</item>");
266            }
267            buffer.push_str(&format!("</{}>", root_element));
268        }
269        _ => {
270            buffer.push_str(&format!("<{}>", root_element));
271            buffer.push_str(&value_to_xml_string(value));
272            buffer.push_str(&format!("</{}>", root_element));
273        }
274    }
275
276    Ok(buffer)
277}
278
279fn serialize_value_to_xml_content(value: &Value) -> Result<String> {
280    let mut result = String::new();
281
282    match value {
283        Value::Object(map) => {
284            for (key, val) in map {
285                let safe_key = sanitize_xml_name(key);
286                result.push_str(&format!("<{}>", safe_key));
287                match val {
288                    Value::Object(_) | Value::Array(_) => {
289                        result.push_str(&serialize_value_to_xml_content(val)?);
290                    }
291                    _ => {
292                        result.push_str(&value_to_xml_string(val));
293                    }
294                }
295                result.push_str(&format!("</{}>", safe_key));
296            }
297        }
298        Value::Array(arr) => {
299            for item in arr {
300                result.push_str("<item>");
301                match item {
302                    Value::Object(_) | Value::Array(_) => {
303                        result.push_str(&serialize_value_to_xml_content(item)?);
304                    }
305                    _ => {
306                        result.push_str(&value_to_xml_string(item));
307                    }
308                }
309                result.push_str("</item>");
310            }
311        }
312        _ => {
313            result.push_str(&value_to_xml_string(value));
314        }
315    }
316
317    Ok(result)
318}
319
320fn value_to_xml_string(value: &Value) -> String {
321    match value {
322        Value::Null => String::new(),
323        Value::Bool(b) => b.to_string(),
324        Value::Number(n) => n.to_string(),
325        Value::String(s) => escape_xml(s),
326        _ => String::new(),
327    }
328}
329
330fn escape_xml(s: &str) -> String {
331    s.replace('&', "&amp;")
332        .replace('<', "&lt;")
333        .replace('>', "&gt;")
334        .replace('"', "&quot;")
335        .replace('\'', "&apos;")
336}
337
338fn sanitize_xml_name(name: &str) -> String {
339    let mut result = String::new();
340
341    for (i, c) in name.chars().enumerate() {
342        if i == 0 {
343            if c.is_ascii_alphabetic() || c == '_' {
344                result.push(c);
345            } else {
346                result.push('_');
347                if c.is_ascii_alphanumeric() {
348                    result.push(c);
349                }
350            }
351        } else if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
352            result.push(c);
353        } else {
354            result.push('_');
355        }
356    }
357
358    if result.is_empty() {
359        result = "_element".to_string();
360    }
361
362    result
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use serde_json::json;
369    use std::sync::Arc;
370
371    /// The engine parameters are resolved against — the same construction
372    /// production uses, so a test cannot agree with an engine that never runs.
373    fn test_engine() -> Engine {
374        crate::engine::compiler::datalogic_engine_builder().build()
375    }
376
377    fn dv(v: serde_json::Value) -> OwnedDataValue {
378        OwnedDataValue::from(&v)
379    }
380
381    fn message_with_data(initial: serde_json::Value) -> Message {
382        Message::builder().data(dv(initial)).build()
383    }
384
385    #[test]
386    fn test_publish_config_from_json() {
387        let input = json!({"source": "output", "target": "json_string"});
388        let config = PublishConfig::from_json(&input).unwrap();
389        assert_eq!(config.source.as_json(), &json!("output"));
390        assert_eq!(config.target.as_json(), &json!("json_string"));
391        assert!(!config.pretty);
392        assert_eq!(config.root_element.as_json(), &json!("root"));
393    }
394
395    #[test]
396    fn test_publish_config_with_options() {
397        let input = json!({
398            "source": "data",
399            "target": "xml_output",
400            "pretty": true,
401            "root_element": "document"
402        });
403
404        let config = PublishConfig::from_json(&input).unwrap();
405        assert_eq!(config.source.as_json(), &json!("data"));
406        assert_eq!(config.target.as_json(), &json!("xml_output"));
407        assert!(config.pretty);
408        assert_eq!(config.root_element.as_json(), &json!("document"));
409    }
410
411    #[test]
412    fn test_publish_config_missing_source() {
413        assert!(PublishConfig::from_json(&json!({"target": "output"})).is_err());
414    }
415
416    #[test]
417    fn test_publish_config_missing_target() {
418        assert!(PublishConfig::from_json(&json!({"source": "input"})).is_err());
419    }
420
421    #[test]
422    fn test_execute_publish_json() {
423        let mut message = message_with_data(json!({"user": {"name": "John", "age": 30}}));
424
425        let config = PublishConfig {
426            source: Template::from(json!("user")),
427            target: PathTemplate::from("user_json"),
428            pretty: false,
429            root_element: Template::from(json!("root")),
430        };
431
432        let result = execute_publish_json(&mut message, &config, &test_engine());
433        assert!(result.is_ok());
434
435        let (outcome, changes) = result.unwrap();
436        assert_eq!(outcome, TaskOutcome::Success);
437        assert_eq!(changes.len(), 1);
438
439        let json_string = message.data()["user_json"].as_str().unwrap();
440        assert!(json_string.contains("John"));
441        assert!(json_string.contains("30"));
442    }
443
444    #[test]
445    fn test_execute_publish_json_pretty() {
446        let mut message = message_with_data(json!({"user": {"name": "Alice"}}));
447
448        let config = PublishConfig {
449            source: Template::from(json!("user")),
450            target: PathTemplate::from("output"),
451            pretty: true,
452            root_element: Template::from(json!("root")),
453        };
454
455        let result = execute_publish_json(&mut message, &config, &test_engine());
456        assert!(result.is_ok());
457
458        let json_string = message.data()["output"].as_str().unwrap();
459        assert!(json_string.contains('\n'));
460    }
461
462    #[test]
463    fn test_execute_publish_json_not_found() {
464        let mut message = Message::new(Arc::new(dv(json!({}))));
465
466        let config = PublishConfig {
467            source: Template::from(json!("nonexistent")),
468            target: PathTemplate::from("output"),
469            pretty: false,
470            root_element: Template::from(json!("root")),
471        };
472
473        assert!(execute_publish_json(&mut message, &config, &test_engine()).is_err());
474    }
475
476    #[test]
477    fn test_execute_publish_xml() {
478        let mut message = message_with_data(json!({"user": {"name": "John", "age": 30}}));
479
480        let config = PublishConfig {
481            source: Template::from(json!("user")),
482            target: PathTemplate::from("user_xml"),
483            pretty: false,
484            root_element: Template::from(json!("user")),
485        };
486
487        let result = execute_publish_xml(&mut message, &config, &test_engine());
488        assert!(result.is_ok());
489
490        let (outcome, _) = result.unwrap();
491        assert_eq!(outcome, TaskOutcome::Success);
492
493        let xml_string = message.data()["user_xml"].as_str().unwrap();
494        assert!(xml_string.contains("<user>"));
495        assert!(xml_string.contains("</user>"));
496        assert!(xml_string.contains("<name>John</name>"));
497    }
498
499    #[test]
500    fn test_execute_publish_xml_not_found() {
501        let mut message = Message::new(Arc::new(dv(json!({}))));
502
503        let config = PublishConfig {
504            source: Template::from(json!("nonexistent")),
505            target: PathTemplate::from("output"),
506            pretty: false,
507            root_element: Template::from(json!("root")),
508        };
509
510        assert!(execute_publish_xml(&mut message, &config, &test_engine()).is_err());
511    }
512
513    #[test]
514    fn test_json_to_xml_simple() {
515        let value = json!({"name": "Test", "value": 42});
516        let xml = json_to_xml(&value, "root").unwrap();
517        assert!(xml.contains("<root>"));
518        assert!(xml.contains("</root>"));
519        assert!(xml.contains("<name>Test</name>"));
520        assert!(xml.contains("<value>42</value>"));
521    }
522
523    #[test]
524    fn test_json_to_xml_nested() {
525        let value = json!({"user": {"name": "Alice", "email": "alice@example.com"}});
526        let xml = json_to_xml(&value, "data").unwrap();
527        assert!(xml.contains("<data>"));
528        assert!(xml.contains("<user>"));
529        assert!(xml.contains("<name>Alice</name>"));
530    }
531
532    #[test]
533    fn test_json_to_xml_array() {
534        let value = json!([1, 2, 3]);
535        let xml = json_to_xml(&value, "numbers").unwrap();
536        assert!(xml.contains("<numbers>"));
537        assert!(xml.contains("<item>1</item>"));
538        assert!(xml.contains("<item>2</item>"));
539        assert!(xml.contains("<item>3</item>"));
540    }
541
542    #[test]
543    fn test_json_to_xml_special_chars() {
544        let value = json!({"text": "<script>alert('xss')</script>"});
545        let xml = json_to_xml(&value, "root").unwrap();
546        assert!(xml.contains("&lt;script&gt;"));
547        assert!(!xml.contains("<script>"));
548    }
549
550    #[test]
551    fn test_escape_xml() {
552        assert_eq!(escape_xml("hello"), "hello");
553        assert_eq!(escape_xml("<tag>"), "&lt;tag&gt;");
554        assert_eq!(escape_xml("a & b"), "a &amp; b");
555        assert_eq!(escape_xml("\"quoted\""), "&quot;quoted&quot;");
556    }
557
558    #[test]
559    fn test_sanitize_xml_name() {
560        assert_eq!(sanitize_xml_name("valid"), "valid");
561        assert_eq!(sanitize_xml_name("_valid"), "_valid");
562        assert_eq!(sanitize_xml_name("123invalid"), "_123invalid");
563        assert_eq!(sanitize_xml_name("has spaces"), "has_spaces");
564        assert_eq!(sanitize_xml_name("has-dash"), "has-dash");
565        assert_eq!(sanitize_xml_name(""), "_element");
566    }
567
568    #[test]
569    fn test_execute_publish_json_nested_source() {
570        let mut message = message_with_data(json!({
571            "response": {"body": {"message": "success"}}
572        }));
573
574        let config = PublishConfig {
575            source: Template::from(json!("response.body")),
576            target: PathTemplate::from("output"),
577            pretty: false,
578            root_element: Template::from(json!("root")),
579        };
580
581        let result = execute_publish_json(&mut message, &config, &test_engine());
582        assert!(result.is_ok());
583
584        let json_string = message.data()["output"].as_str().unwrap();
585        assert!(json_string.contains("success"));
586    }
587}