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