Skip to main content

faucet_core/file_format/
xml.rs

1//! XML read/write for the file connectors (#604).
2//!
3//! The decoder is the compact element→object mapping the REST source has used
4//! since #515: each element becomes an object of its children, repeated child
5//! tags become arrays, attributes are `@name`, and text is `#text` (or the
6//! value directly when an element has only text). Namespaces are stripped to
7//! their local name.
8//!
9//! XML has **no canonical record boundary**, so one is declared
10//! ([`XmlOptions::record_element`](super::XmlOptions)). Selecting the wrong
11//! element yields no records rather than the wrong ones, which is why the
12//! decoder reports the elements it did see.
13
14use crate::error::FaucetError;
15use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
16use serde_json::{Map, Value};
17
18/// Parse XML bytes into records.
19///
20/// `record_element` names the repeated element. When no element of that name
21/// exists, the document root's direct children are used instead — right for the
22/// common `<rows><row/>…</rows>` shape without forcing every config to spell it
23/// out, and reported in the error when neither yields anything.
24pub fn decode(bytes: &[u8], record_element: &str) -> Result<Vec<Value>, FaucetError> {
25    let doc = to_json(bytes)?;
26    let mut out = Vec::new();
27    collect(&doc, record_element, &mut out);
28    if !out.is_empty() {
29        return Ok(out);
30    }
31    // Fall back to the root's children.
32    match root_children(&doc) {
33        Some(children) => Ok(children),
34        // An empty document element is zero records, not an error: a sink that
35        // wrote an empty page produces exactly this, and failing the read
36        // would turn "no data" into a pipeline failure.
37        None if is_empty_document(&doc) => Ok(Vec::new()),
38        None => Err(FaucetError::Source(format!(
39            "xml: no <{record_element}> elements, and the document root has no repeated child to \
40             use instead — set `xml.record_element` to the element that delimits one record"
41        ))),
42    }
43}
44
45/// Every value stored under `key`, at any depth, flattened out of arrays.
46fn collect(v: &Value, key: &str, out: &mut Vec<Value>) {
47    match v {
48        Value::Object(map) => {
49            for (k, child) in map {
50                if k == key {
51                    match child {
52                        Value::Array(items) => out.extend(items.iter().cloned()),
53                        other => out.push(other.clone()),
54                    }
55                } else {
56                    collect(child, key, out);
57                }
58            }
59        }
60        Value::Array(items) => {
61            for i in items {
62                collect(i, key, out);
63            }
64        }
65        _ => {}
66    }
67}
68
69/// Whether the document is a single element with no children and no text.
70fn is_empty_document(doc: &Value) -> bool {
71    let Some(root) = doc.as_object().and_then(|m| m.values().next()) else {
72        return true;
73    };
74    match root {
75        Value::String(s) => s.trim().is_empty(),
76        Value::Object(map) => map.is_empty(),
77        _ => false,
78    }
79}
80
81/// The document root's children, when the root wraps exactly one repeated
82/// element. Returns `None` for a root with several differently-named children,
83/// where "the records" would be a guess.
84fn root_children(doc: &Value) -> Option<Vec<Value>> {
85    let root = doc.as_object()?.values().next()?;
86    let map = root.as_object()?;
87    let real: Vec<(&String, &Value)> = map
88        .iter()
89        .filter(|(k, _)| !k.starts_with('@') && *k != "#text")
90        .collect();
91    match real.as_slice() {
92        [(_, Value::Array(items))] => Some(items.to_vec()),
93        [(_, single)] => Some(vec![(*single).clone()]),
94        _ => None,
95    }
96}
97
98/// Any writer failure, whatever `quick_xml` calls it in this version.
99fn err<E: std::fmt::Display>(e: E) -> FaucetError {
100    FaucetError::Sink(format!("xml: {e}"))
101}
102
103/// Write records as XML: `<root><record>…</record>…</root>`.
104///
105/// Scalars become text elements; a nested object becomes a nested element; an
106/// array becomes a repeated element, which is the shape [`decode`] reads back.
107pub fn encode(
108    records: &[Value],
109    root_element: &str,
110    record_element: &str,
111) -> Result<Vec<u8>, FaucetError> {
112    let mut w = quick_xml::Writer::new(Vec::new());
113    w.write_event(Event::Start(BytesStart::new(root_element)))
114        .map_err(err)?;
115    for r in records {
116        w.write_event(Event::Start(BytesStart::new(record_element)))
117            .map_err(err)?;
118        write_value(&mut w, r)?;
119        w.write_event(Event::End(BytesEnd::new(record_element)))
120            .map_err(err)?;
121    }
122    w.write_event(Event::End(BytesEnd::new(root_element)))
123        .map_err(err)?;
124    Ok(w.into_inner())
125}
126
127fn write_value(w: &mut quick_xml::Writer<Vec<u8>>, v: &Value) -> Result<(), FaucetError> {
128    match v {
129        Value::Object(map) => {
130            for (k, child) in map {
131                let name = sanitize(k);
132                match child {
133                    Value::Array(items) => {
134                        for item in items {
135                            w.write_event(Event::Start(BytesStart::new(&name)))
136                                .map_err(err)?;
137                            write_value(w, item)?;
138                            w.write_event(Event::End(BytesEnd::new(&name)))
139                                .map_err(err)?;
140                        }
141                    }
142                    other => {
143                        w.write_event(Event::Start(BytesStart::new(&name)))
144                            .map_err(err)?;
145                        write_value(w, other)?;
146                        w.write_event(Event::End(BytesEnd::new(&name)))
147                            .map_err(err)?;
148                    }
149                }
150            }
151            Ok(())
152        }
153        Value::Null => Ok(()),
154        other => w
155            .write_event(Event::Text(BytesText::new(&super::cell_text(other))))
156            .map_err(err),
157    }
158}
159
160/// A field name that is a legal XML element name.
161///
162/// JSON keys can hold spaces, slashes and leading digits; an element name
163/// cannot. Substituting `_` keeps the document well-formed and the mapping
164/// obvious, which beats failing the write on a field the user cannot rename.
165fn sanitize(key: &str) -> String {
166    let mut out = String::with_capacity(key.len());
167    for (i, c) in key.chars().enumerate() {
168        let ok = c.is_alphanumeric() || c == '_' || c == '-' || c == '.';
169        let ok = ok && !(i == 0 && (c.is_numeric() || c == '-' || c == '.'));
170        out.push(if ok { c } else { '_' });
171    }
172    if out.is_empty() { "field".into() } else { out }
173}
174
175/// One open element: its object and its accumulated text.
176type Frame = (Map<String, Value>, String);
177
178fn unbalanced(detail: &str) -> FaucetError {
179    FaucetError::Source(format!(
180        "xml: malformed XML: {detail}; the document has more closing than opening tags"
181    ))
182}
183
184fn top<'a>(stack: &'a mut [Frame], detail: &str) -> Result<&'a mut Frame, FaucetError> {
185    stack.last_mut().ok_or_else(|| unbalanced(detail))
186}
187
188fn pop(stack: &mut Vec<Frame>, detail: &str) -> Result<Frame, FaucetError> {
189    stack.pop().ok_or_else(|| unbalanced(detail))
190}
191
192fn attrs(e: &BytesStart) -> Map<String, Value> {
193    let mut m = Map::new();
194    for a in e.attributes().flatten() {
195        let k = local(a.key.as_ref());
196        if let Ok(v) = a.unescape_value() {
197            m.insert(format!("@{k}"), Value::String(v.to_string()));
198        }
199    }
200    m
201}
202
203fn local(name: &[u8]) -> String {
204    String::from_utf8_lossy(name)
205        .rsplit(':')
206        .next()
207        .unwrap_or_default()
208        .to_string()
209}
210
211fn insert_child(parent: &mut Map<String, Value>, key: String, val: Value) {
212    match parent.get_mut(&key) {
213        Some(Value::Array(arr)) => arr.push(val),
214        Some(existing) => {
215            let prev = existing.take();
216            parent.insert(key, Value::Array(vec![prev, val]));
217        }
218        None => {
219            parent.insert(key, val);
220        }
221    }
222}
223
224fn finish(obj: Map<String, Value>, text: String) -> Value {
225    let trimmed = text.trim();
226    if obj.is_empty() {
227        Value::String(trimmed.to_string())
228    } else {
229        let mut obj = obj;
230        if !trimmed.is_empty() {
231            obj.insert("#text".to_string(), Value::String(trimmed.to_string()));
232        }
233        Value::Object(obj)
234    }
235}
236
237/// Compact XML → JSON.
238pub fn to_json(bytes: &[u8]) -> Result<Value, FaucetError> {
239    let text = std::str::from_utf8(bytes)
240        .map_err(|e| FaucetError::Source(format!("xml: not UTF-8: {e}")))?;
241    let mut reader = quick_xml::Reader::from_str(text);
242    let mut stack: Vec<Frame> = vec![(Map::new(), String::new())];
243    let mut names: Vec<String> = Vec::new();
244
245    loop {
246        match reader
247            .read_event()
248            .map_err(|e| FaucetError::Source(format!("xml: {e}")))?
249        {
250            Event::Eof => break,
251            Event::Start(e) => {
252                names.push(local(e.name().as_ref()));
253                stack.push((attrs(&e), String::new()));
254            }
255            Event::Empty(e) => {
256                let name = local(e.name().as_ref());
257                let val = finish(attrs(&e), String::new());
258                let t = top(&mut stack, "element after the document root closed")?;
259                insert_child(&mut t.0, name, val);
260            }
261            Event::Text(t) => {
262                let s = t
263                    .unescape()
264                    .map_err(|e| FaucetError::Source(format!("xml: {e}")))?
265                    .to_string();
266                top(&mut stack, "text after the document root closed")?
267                    .1
268                    .push_str(&s);
269            }
270            Event::CData(t) => {
271                let s = String::from_utf8_lossy(t.as_ref()).to_string();
272                top(&mut stack, "CDATA after the document root closed")?
273                    .1
274                    .push_str(&s);
275            }
276            Event::End(_) => {
277                let (obj, text) = pop(&mut stack, "unmatched closing tag")?;
278                let name = names
279                    .pop()
280                    .ok_or_else(|| unbalanced("unmatched closing tag"))?;
281                let val = finish(obj, text);
282                let parent = top(&mut stack, "unmatched closing tag")?;
283                insert_child(&mut parent.0, name, val);
284            }
285            _ => {}
286        }
287    }
288    let (root, _) = stack
289        .pop()
290        .ok_or_else(|| unbalanced("document root closed twice"))?;
291    Ok(Value::Object(root))
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use serde_json::json;
298
299    #[test]
300    fn a_named_record_element_selects_the_records() {
301        let xml = br#"<rows><row><id>1</id><n>a</n></row><row><id>2</id><n>b</n></row></rows>"#;
302        assert_eq!(
303            decode(xml, "row").expect("decode"),
304            vec![json!({"id": "1", "n": "a"}), json!({"id": "2", "n": "b"})]
305        );
306    }
307
308    #[test]
309    fn a_single_record_is_still_a_list_of_one() {
310        let xml = br#"<rows><row><id>1</id></row></rows>"#;
311        assert_eq!(
312            decode(xml, "row").expect("decode"),
313            vec![json!({"id": "1"})]
314        );
315    }
316
317    #[test]
318    fn the_record_element_is_found_at_any_depth() {
319        let xml = br#"<env><body><rows><row><id>1</id></row></rows></body></env>"#;
320        assert_eq!(
321            decode(xml, "row").expect("decode"),
322            vec![json!({"id": "1"})]
323        );
324    }
325
326    #[test]
327    fn without_a_match_the_roots_children_are_used() {
328        // `<rows><row/>…</rows>` read with the default `record` name still
329        // works, rather than silently returning nothing.
330        let xml = br#"<rows><row><id>1</id></row><row><id>2</id></row></rows>"#;
331        assert_eq!(decode(xml, "record").expect("decode").len(), 2);
332    }
333
334    #[test]
335    fn a_self_closing_element_and_cdata_decode() {
336        // `Event::Empty` and `Event::CData` are separate arms from Start/Text.
337        let v = to_json(br#"<r><e/><c><![CDATA[raw <>&]]></c><!-- ignored --></r>"#).expect("json");
338        assert_eq!(v["r"]["e"], json!(""));
339        assert_eq!(v["r"]["c"], json!("raw <>&"));
340    }
341
342    #[test]
343    fn three_repeated_elements_accumulate_into_one_array() {
344        // The second occurrence promotes the value to an array; the third
345        // takes the push branch.
346        let recs = decode(br#"<rs><r>a</r><r>b</r><r>c</r></rs>"#, "r").expect("decode");
347        assert_eq!(recs, vec![json!("a"), json!("b"), json!("c")]);
348    }
349
350    #[test]
351    fn a_root_wrapping_a_single_child_still_yields_one_record() {
352        // `root_children` has a distinct arm for a lone non-array child.
353        let recs = decode(br#"<rows><row><id>1</id></row></rows>"#, "nope").expect("decode");
354        assert_eq!(recs, vec![json!({"id": "1"})]);
355    }
356
357    #[test]
358    fn emptiness_is_judged_on_the_root_shape() {
359        assert!(is_empty_document(&json!({})));
360        assert!(is_empty_document(&json!({"r": ""})));
361        assert!(is_empty_document(&json!({"r": "   "})));
362        assert!(is_empty_document(&json!({"r": {}})));
363        assert!(!is_empty_document(&json!({"r": "text"})));
364        assert!(!is_empty_document(&json!({"r": {"a": 1}})));
365        assert!(!is_empty_document(&json!({"r": [1]})));
366    }
367
368    #[test]
369    fn the_writer_error_helper_is_prefixed() {
370        assert_eq!(err("boom").to_string(), "Sink error: xml: boom");
371    }
372
373    #[test]
374    fn an_empty_document_reads_back_as_zero_records_not_an_error() {
375        let bytes = encode(&[], "records", "record").expect("encode");
376        assert_eq!(
377            String::from_utf8(bytes.clone()).unwrap(),
378            "<records></records>"
379        );
380        assert!(decode(&bytes, "record").expect("decode").is_empty());
381    }
382
383    #[test]
384    fn an_ambiguous_root_is_an_error_naming_the_option() {
385        let xml = br#"<doc><a>1</a><b>2</b></doc>"#;
386        let err = decode(xml, "record").expect_err("ambiguous");
387        assert!(err.to_string().contains("xml.record_element"), "{err}");
388    }
389
390    #[test]
391    fn attributes_text_and_namespaces_map_compactly() {
392        let xml = br#"<r><x ns:k="v">t</x></r>"#;
393        let v = to_json(xml).expect("json");
394        assert_eq!(v["r"]["x"]["@k"], json!("v"));
395        assert_eq!(v["r"]["x"]["#text"], json!("t"));
396    }
397
398    #[test]
399    fn unbalanced_input_is_a_typed_error_not_a_panic() {
400        // Untrusted input must never panic a live pipeline. `quick_xml`'s own
401        // `check_end_names` usually rejects the mismatch first, so the message
402        // is normally its — the guard here is that the frame-stack accesses
403        // report rather than `.expect()`, whichever layer catches it.
404        let err = to_json(b"<a></a></a>").expect_err("unbalanced");
405        assert!(matches!(err, FaucetError::Source(_)), "{err}");
406        assert!(unbalanced("x").to_string().contains("malformed XML"));
407    }
408
409    #[test]
410    fn encode_round_trips_through_decode() {
411        let recs = vec![json!({"id": "1", "n": "a"}), json!({"id": "2", "n": "b"})];
412        let bytes = encode(&recs, "records", "record").expect("encode");
413        assert_eq!(
414            String::from_utf8(bytes.clone()).unwrap(),
415            "<records><record><id>1</id><n>a</n></record><record><id>2</id><n>b</n></record></records>"
416        );
417        assert_eq!(decode(&bytes, "record").expect("decode"), recs);
418    }
419
420    #[test]
421    fn an_array_field_becomes_a_repeated_element() {
422        let bytes = encode(&[json!({"t": ["a", "b"]})], "rs", "r").expect("encode");
423        assert_eq!(
424            String::from_utf8(bytes).unwrap(),
425            "<rs><r><t>a</t><t>b</t></r></rs>"
426        );
427    }
428
429    #[test]
430    fn a_key_that_is_not_a_legal_element_name_is_substituted_not_rejected() {
431        let bytes = encode(&[json!({"a b/c": 1, "2x": 2})], "rs", "r").expect("encode");
432        let s = String::from_utf8(bytes).unwrap();
433        assert!(s.contains("<a_b_c>1</a_b_c>"), "{s}");
434        assert!(s.contains("<_x>2</_x>"), "{s}");
435    }
436
437    #[test]
438    fn a_null_field_writes_an_empty_element() {
439        let bytes = encode(&[json!({"a": null})], "rs", "r").expect("encode");
440        assert_eq!(String::from_utf8(bytes).unwrap(), "<rs><r><a></a></r></rs>");
441    }
442}