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            removed: false,
153        }],
154    )
155}
156
157/// Execute `publish_json`: serialise `data.{source}` to a JSON string and
158/// store at `data.{target}`.
159pub fn execute_publish_json(
160    message: &mut Message,
161    config: &PublishConfig,
162    engine: &Engine,
163) -> Result<(TaskOutcome, Vec<Change>)> {
164    with_arena(|arena| {
165        let arena_ctx = ArenaContext::from_owned(&message.context, arena);
166        publish_json_in(message, config, ParamCtx::from_arena(engine, &arena_ctx))
167    })
168}
169
170pub(crate) fn publish_json_in(
171    message: &mut Message,
172    config: &PublishConfig,
173    p: ParamCtx<'_>,
174) -> Result<(TaskOutcome, Vec<Change>)> {
175    let source = config.source.resolve_str_in_arena(p)?;
176    let target = config.resolve_target_path(p)?;
177    debug!("PublishJson: Serializing 'data.{source}' to '{}'", target.0);
178
179    // Borrowed resolve — a missing path and an explicit Null both reject,
180    // matching the historical extract_source contract.
181    let source_data = match config.resolve_source(message, &source) {
182        Some(v) if !matches!(v, OwnedDataValue::Null) => v,
183        _ => {
184            return Err(DataflowError::Validation(format!(
185                "PublishJson: Source 'data.{source}' not found or is null"
186            )));
187        }
188    };
189
190    // For compact JSON, use OwnedDataValue's native emitter (fastest path).
191    // For pretty JSON, bridge to serde_json::Value — pretty publish is not a
192    // hot path and the bridge cost there is irrelevant. Either way the
193    // serializer reads through the borrow; the source subtree is never
194    // deep-cloned.
195    let json_string = if config.pretty {
196        let bridge = Value::from(source_data);
197        serde_json::to_string_pretty(&bridge)
198            .map_err(|e| DataflowError::Validation(format!("Failed to serialize to JSON: {}", e)))?
199    } else {
200        source_data.to_json_string()
201    };
202
203    Ok(finish_publish(message, &target, json_string))
204}
205
206/// Execute `publish_xml`: serialise `data.{source}` to an XML string and
207/// store at `data.{target}`. Bridges to `serde_json::Value` for the existing
208/// recursive XML walker — XML is the slow path, no perf concern.
209pub fn execute_publish_xml(
210    message: &mut Message,
211    config: &PublishConfig,
212    engine: &Engine,
213) -> Result<(TaskOutcome, Vec<Change>)> {
214    with_arena(|arena| {
215        let arena_ctx = ArenaContext::from_owned(&message.context, arena);
216        publish_xml_in(message, config, ParamCtx::from_arena(engine, &arena_ctx))
217    })
218}
219
220pub(crate) fn publish_xml_in(
221    message: &mut Message,
222    config: &PublishConfig,
223    p: ParamCtx<'_>,
224) -> Result<(TaskOutcome, Vec<Change>)> {
225    let source = config.source.resolve_str_in_arena(p)?;
226    let target = config.resolve_target_path(p)?;
227    debug!("PublishXml: Serializing 'data.{source}' to '{}'", target.0);
228
229    // Borrowed resolve — same contract as the JSON path: missing and
230    // explicit-Null sources both reject, no source deep clone.
231    let source_data = match config.resolve_source(message, &source) {
232        Some(v) if !matches!(v, OwnedDataValue::Null) => v,
233        _ => {
234            return Err(DataflowError::Validation(format!(
235                "PublishXml: Source 'data.{source}' not found or is null"
236            )));
237        }
238    };
239
240    let bridge = Value::from(source_data);
241    let root_element = config.root_element.resolve_str_in_arena(p)?;
242    let xml_string = json_to_xml(&bridge, &root_element)?;
243
244    Ok(finish_publish(message, &target, xml_string))
245}
246
247/// Convert JSON Value to XML string. Recursive walker; same shape as before
248/// the OwnedDataValue refactor — kept on `serde_json::Value` since XML is the
249/// slow path.
250fn json_to_xml(value: &Value, root_element: &str) -> Result<String> {
251    let mut buffer = String::new();
252
253    match value {
254        Value::Object(_) => {
255            buffer.push_str(&format!("<{}>", root_element));
256            let content = serialize_value_to_xml_content(value)?;
257            buffer.push_str(&content);
258            buffer.push_str(&format!("</{}>", root_element));
259        }
260        Value::Array(arr) => {
261            buffer.push_str(&format!("<{}>", root_element));
262            for item in arr {
263                buffer.push_str("<item>");
264                let content = serialize_value_to_xml_content(item)?;
265                buffer.push_str(&content);
266                buffer.push_str("</item>");
267            }
268            buffer.push_str(&format!("</{}>", root_element));
269        }
270        _ => {
271            buffer.push_str(&format!("<{}>", root_element));
272            buffer.push_str(&value_to_xml_string(value));
273            buffer.push_str(&format!("</{}>", root_element));
274        }
275    }
276
277    Ok(buffer)
278}
279
280fn serialize_value_to_xml_content(value: &Value) -> Result<String> {
281    let mut result = String::new();
282
283    match value {
284        Value::Object(map) => {
285            for (key, val) in map {
286                let safe_key = sanitize_xml_name(key);
287                result.push_str(&format!("<{}>", safe_key));
288                match val {
289                    Value::Object(_) | Value::Array(_) => {
290                        result.push_str(&serialize_value_to_xml_content(val)?);
291                    }
292                    _ => {
293                        result.push_str(&value_to_xml_string(val));
294                    }
295                }
296                result.push_str(&format!("</{}>", safe_key));
297            }
298        }
299        Value::Array(arr) => {
300            for item in arr {
301                result.push_str("<item>");
302                match item {
303                    Value::Object(_) | Value::Array(_) => {
304                        result.push_str(&serialize_value_to_xml_content(item)?);
305                    }
306                    _ => {
307                        result.push_str(&value_to_xml_string(item));
308                    }
309                }
310                result.push_str("</item>");
311            }
312        }
313        _ => {
314            result.push_str(&value_to_xml_string(value));
315        }
316    }
317
318    Ok(result)
319}
320
321fn value_to_xml_string(value: &Value) -> String {
322    match value {
323        Value::Null => String::new(),
324        Value::Bool(b) => b.to_string(),
325        Value::Number(n) => n.to_string(),
326        Value::String(s) => escape_xml(s),
327        _ => String::new(),
328    }
329}
330
331fn escape_xml(s: &str) -> String {
332    s.replace('&', "&amp;")
333        .replace('<', "&lt;")
334        .replace('>', "&gt;")
335        .replace('"', "&quot;")
336        .replace('\'', "&apos;")
337}
338
339fn sanitize_xml_name(name: &str) -> String {
340    let mut result = String::new();
341
342    for (i, c) in name.chars().enumerate() {
343        if i == 0 {
344            if c.is_ascii_alphabetic() || c == '_' {
345                result.push(c);
346            } else {
347                result.push('_');
348                if c.is_ascii_alphanumeric() {
349                    result.push(c);
350                }
351            }
352        } else if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
353            result.push(c);
354        } else {
355            result.push('_');
356        }
357    }
358
359    if result.is_empty() {
360        result = "_element".to_string();
361    }
362
363    result
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use serde_json::json;
370    use std::sync::Arc;
371
372    /// The engine parameters are resolved against — the same construction
373    /// production uses, so a test cannot agree with an engine that never runs.
374    fn test_engine() -> Engine {
375        crate::engine::compiler::datalogic_engine_builder().build()
376    }
377
378    fn dv(v: serde_json::Value) -> OwnedDataValue {
379        OwnedDataValue::from(&v)
380    }
381
382    fn message_with_data(initial: serde_json::Value) -> Message {
383        Message::builder().data(dv(initial)).build()
384    }
385
386    #[test]
387    fn test_publish_config_from_json() {
388        let input = json!({"source": "output", "target": "json_string"});
389        let config = PublishConfig::from_json(&input).unwrap();
390        assert_eq!(config.source.as_json(), &json!("output"));
391        assert_eq!(config.target.as_json(), &json!("json_string"));
392        assert!(!config.pretty);
393        assert_eq!(config.root_element.as_json(), &json!("root"));
394    }
395
396    #[test]
397    fn test_publish_config_with_options() {
398        let input = json!({
399            "source": "data",
400            "target": "xml_output",
401            "pretty": true,
402            "root_element": "document"
403        });
404
405        let config = PublishConfig::from_json(&input).unwrap();
406        assert_eq!(config.source.as_json(), &json!("data"));
407        assert_eq!(config.target.as_json(), &json!("xml_output"));
408        assert!(config.pretty);
409        assert_eq!(config.root_element.as_json(), &json!("document"));
410    }
411
412    #[test]
413    fn test_publish_config_missing_source() {
414        assert!(PublishConfig::from_json(&json!({"target": "output"})).is_err());
415    }
416
417    #[test]
418    fn test_publish_config_missing_target() {
419        assert!(PublishConfig::from_json(&json!({"source": "input"})).is_err());
420    }
421
422    #[test]
423    fn test_execute_publish_json() {
424        let mut message = message_with_data(json!({"user": {"name": "John", "age": 30}}));
425
426        let config = PublishConfig {
427            source: Template::from(json!("user")),
428            target: PathTemplate::from("user_json"),
429            pretty: false,
430            root_element: Template::from(json!("root")),
431        };
432
433        let result = execute_publish_json(&mut message, &config, &test_engine());
434        assert!(result.is_ok());
435
436        let (outcome, changes) = result.unwrap();
437        assert_eq!(outcome, TaskOutcome::Success);
438        assert_eq!(changes.len(), 1);
439
440        let json_string = message.data()["user_json"].as_str().unwrap();
441        assert!(json_string.contains("John"));
442        assert!(json_string.contains("30"));
443    }
444
445    #[test]
446    fn test_execute_publish_json_pretty() {
447        let mut message = message_with_data(json!({"user": {"name": "Alice"}}));
448
449        let config = PublishConfig {
450            source: Template::from(json!("user")),
451            target: PathTemplate::from("output"),
452            pretty: true,
453            root_element: Template::from(json!("root")),
454        };
455
456        let result = execute_publish_json(&mut message, &config, &test_engine());
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: Template::from(json!("nonexistent")),
469            target: PathTemplate::from("output"),
470            pretty: false,
471            root_element: Template::from(json!("root")),
472        };
473
474        assert!(execute_publish_json(&mut message, &config, &test_engine()).is_err());
475    }
476
477    #[test]
478    fn test_execute_publish_xml() {
479        let mut message = message_with_data(json!({"user": {"name": "John", "age": 30}}));
480
481        let config = PublishConfig {
482            source: Template::from(json!("user")),
483            target: PathTemplate::from("user_xml"),
484            pretty: false,
485            root_element: Template::from(json!("user")),
486        };
487
488        let result = execute_publish_xml(&mut message, &config, &test_engine());
489        assert!(result.is_ok());
490
491        let (outcome, _) = result.unwrap();
492        assert_eq!(outcome, TaskOutcome::Success);
493
494        let xml_string = message.data()["user_xml"].as_str().unwrap();
495        assert!(xml_string.contains("<user>"));
496        assert!(xml_string.contains("</user>"));
497        assert!(xml_string.contains("<name>John</name>"));
498    }
499
500    #[test]
501    fn test_execute_publish_xml_not_found() {
502        let mut message = Message::new(Arc::new(dv(json!({}))));
503
504        let config = PublishConfig {
505            source: Template::from(json!("nonexistent")),
506            target: PathTemplate::from("output"),
507            pretty: false,
508            root_element: Template::from(json!("root")),
509        };
510
511        assert!(execute_publish_xml(&mut message, &config, &test_engine()).is_err());
512    }
513
514    #[test]
515    fn test_json_to_xml_simple() {
516        let value = json!({"name": "Test", "value": 42});
517        let xml = json_to_xml(&value, "root").unwrap();
518        assert!(xml.contains("<root>"));
519        assert!(xml.contains("</root>"));
520        assert!(xml.contains("<name>Test</name>"));
521        assert!(xml.contains("<value>42</value>"));
522    }
523
524    #[test]
525    fn test_json_to_xml_nested() {
526        let value = json!({"user": {"name": "Alice", "email": "alice@example.com"}});
527        let xml = json_to_xml(&value, "data").unwrap();
528        assert!(xml.contains("<data>"));
529        assert!(xml.contains("<user>"));
530        assert!(xml.contains("<name>Alice</name>"));
531    }
532
533    #[test]
534    fn test_json_to_xml_array() {
535        let value = json!([1, 2, 3]);
536        let xml = json_to_xml(&value, "numbers").unwrap();
537        assert!(xml.contains("<numbers>"));
538        assert!(xml.contains("<item>1</item>"));
539        assert!(xml.contains("<item>2</item>"));
540        assert!(xml.contains("<item>3</item>"));
541    }
542
543    #[test]
544    fn test_json_to_xml_special_chars() {
545        let value = json!({"text": "<script>alert('xss')</script>"});
546        let xml = json_to_xml(&value, "root").unwrap();
547        assert!(xml.contains("&lt;script&gt;"));
548        assert!(!xml.contains("<script>"));
549    }
550
551    #[test]
552    fn test_escape_xml() {
553        assert_eq!(escape_xml("hello"), "hello");
554        assert_eq!(escape_xml("<tag>"), "&lt;tag&gt;");
555        assert_eq!(escape_xml("a & b"), "a &amp; b");
556        assert_eq!(escape_xml("\"quoted\""), "&quot;quoted&quot;");
557    }
558
559    #[test]
560    fn test_sanitize_xml_name() {
561        assert_eq!(sanitize_xml_name("valid"), "valid");
562        assert_eq!(sanitize_xml_name("_valid"), "_valid");
563        assert_eq!(sanitize_xml_name("123invalid"), "_123invalid");
564        assert_eq!(sanitize_xml_name("has spaces"), "has_spaces");
565        assert_eq!(sanitize_xml_name("has-dash"), "has-dash");
566        assert_eq!(sanitize_xml_name(""), "_element");
567    }
568
569    #[test]
570    fn test_execute_publish_json_nested_source() {
571        let mut message = message_with_data(json!({
572            "response": {"body": {"message": "success"}}
573        }));
574
575        let config = PublishConfig {
576            source: Template::from(json!("response.body")),
577            target: PathTemplate::from("output"),
578            pretty: false,
579            root_element: Template::from(json!("root")),
580        };
581
582        let result = execute_publish_json(&mut message, &config, &test_engine());
583        assert!(result.is_ok());
584
585        let json_string = message.data()["output"].as_str().unwrap();
586        assert!(json_string.contains("success"));
587    }
588}