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