Skip to main content

rustyqlib/core/
serialization.rs

1//! Format-agnostic contract input and output: JSON and XML.
2//!
3//! XML is treated as an alternative *syntax* for the same data model, not
4//! as a second schema. Documents are transcoded to [`serde_json::Value`]
5//! and then deserialized with the existing serde derives, so both formats
6//! share one definition, one set of defaults and one set of validation
7//! rules — and a new product supports both the moment it is added.
8//!
9//! (Deriving XML directly is not an option here: the data model relies on
10//! `#[serde(flatten)]`, internally tagged enums and untagged enums, none of
11//! which XML serde implementations support.)
12//!
13//! # XML conventions
14//!
15//! - **Elements are object fields.** `<strike_price>100</strike_price>`
16//!   becomes `"strike_price": 100`.
17//! - **Attributes are object fields too**, which reads naturally for the
18//!   tag of a tagged enum: `<discount_curve type="flat">` is the same as
19//!   `"discount_curve": { "type": "flat", ... }`.
20//! - **`<item>` children make an array.** `<tenors><item>0.5</item>
21//!   <item>1.0</item></tenors>` becomes `"tenors": [0.5, 1.0]`, and a
22//!   single `<item>` still yields a one-element array. Repeated non-`item`
23//!   siblings also collapse into an array.
24//! - **Scalars are inferred**: `true`/`false` become booleans, anything
25//!   parsing as a number becomes a number, an empty element becomes null,
26//!   everything else stays a string (so `2027-07-17` and `C` are safe).
27
28use std::fmt::Write as _;
29use std::path::Path;
30
31use quick_xml::events::Event;
32use quick_xml::Reader;
33use serde_json::{Map, Value};
34
35/// Element name that marks array members in XML.
36pub const ARRAY_ITEM: &str = "item";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Format {
40    Json,
41    Xml,
42}
43
44impl Format {
45    /// Format implied by a file extension; `None` if unrecognised.
46    pub fn from_path<P: AsRef<Path>>(path: P) -> Option<Format> {
47        match path
48            .as_ref()
49            .extension()
50            .and_then(|e| e.to_str())
51            .map(|e| e.to_lowercase())
52            .as_deref()
53        {
54            Some("xml") => Some(Format::Xml),
55            Some("json") => Some(Format::Json),
56            _ => None,
57        }
58    }
59
60    /// Format sniffed from document content: a leading `<` means XML.
61    pub fn detect(content: &str) -> Format {
62        match content.trim_start().chars().next() {
63            Some('<') => Format::Xml,
64            _ => Format::Json,
65        }
66    }
67
68    pub fn extension(&self) -> &'static str {
69        match self {
70            Format::Json => "json",
71            Format::Xml => "xml",
72        }
73    }
74}
75
76// ── Input ───────────────────────────────────────────────────────────────
77
78/// Parse a document in either format into a [`Value`].
79pub fn parse_value(content: &str, format: Format) -> Result<Value, String> {
80    match format {
81        Format::Json => serde_json::from_str(content).map_err(|e| format!("invalid JSON: {e}")),
82        Format::Xml => xml_to_value(content),
83    }
84}
85
86/// Parse a document into any deserializable type, in either format.
87pub fn parse<T: serde::de::DeserializeOwned>(content: &str, format: Format) -> Result<T, String> {
88    let value = parse_value(content, format)?;
89    serde_json::from_value(value).map_err(|e| format!("document does not match the schema: {e}"))
90}
91
92/// Transcode an XML document into the equivalent [`Value`].
93pub fn xml_to_value(xml: &str) -> Result<Value, String> {
94    let mut reader = Reader::from_str(xml);
95    // text is accumulated raw and trimmed when the element closes, so
96    // indentation is discarded without collapsing interior whitespace
97    reader.config_mut().expand_empty_elements = false;
98
99    // stack of partially built elements
100    let mut stack: Vec<Node> = Vec::new();
101    let mut root: Option<(String, Value)> = None;
102
103    loop {
104        match reader.read_event() {
105            Ok(Event::Start(e)) => stack.push(Node::start(&e)?),
106            Ok(Event::Empty(e)) => {
107                let node = Node::start(&e)?;
108                let (name, value) = node.finish();
109                attach(&mut stack, &mut root, name, value)?;
110            }
111            Ok(Event::Text(e)) => {
112                if let Some(node) = stack.last_mut() {
113                    let text = e
114                        .decode()
115                        .map_err(|err| format!("invalid text content: {err}"))?;
116                    node.text.push_str(text.as_ref());
117                }
118            }
119            Ok(Event::CData(e)) => {
120                if let Some(node) = stack.last_mut() {
121                    let text = String::from_utf8(e.into_inner().into_owned())
122                        .map_err(|err| format!("invalid CDATA: {err}"))?;
123                    node.text.push_str(&text);
124                }
125            }
126            // entity references arrive as their own events
127            Ok(Event::GeneralRef(e)) => {
128                if let Some(node) = stack.last_mut() {
129                    node.text.push_str(&resolve_entity(&e)?);
130                }
131            }
132            Ok(Event::End(_)) => {
133                let node = stack.pop().ok_or_else(|| "unbalanced closing tag".to_string())?;
134                let (name, value) = node.finish();
135                attach(&mut stack, &mut root, name, value)?;
136            }
137            Ok(Event::Eof) => break,
138            Ok(_) => {} // declaration, comments, processing instructions
139            Err(e) => return Err(format!("malformed XML at byte {}: {e}", reader.buffer_position())),
140        }
141    }
142    if !stack.is_empty() {
143        return Err("unbalanced XML: unclosed elements".to_string());
144    }
145    match root {
146        // the document element is the top-level object
147        Some((_, value)) => Ok(value),
148        None => Err("empty XML document".to_string()),
149    }
150}
151
152struct Node {
153    name: String,
154    /// attributes, in document order
155    attrs: Vec<(String, Value)>,
156    /// child elements, in document order
157    children: Vec<(String, Value)>,
158    text: String,
159}
160
161impl Node {
162    fn start(e: &quick_xml::events::BytesStart) -> Result<Node, String> {
163        let name = String::from_utf8(e.name().as_ref().to_vec())
164            .map_err(|err| format!("invalid element name: {err}"))?;
165        let mut attrs = Vec::new();
166        for attr in e.attributes() {
167            let attr = attr.map_err(|err| format!("invalid attribute in <{name}>: {err}"))?;
168            let key = String::from_utf8(attr.key.as_ref().to_vec())
169                .map_err(|err| format!("invalid attribute name: {err}"))?;
170            let raw = attr
171                .unescape_value()
172                .map_err(|err| format!("invalid attribute value in <{name}>: {err}"))?;
173            attrs.push((key, infer_scalar(raw.as_ref())));
174        }
175        Ok(Node { name, attrs, children: Vec::new(), text: String::new() })
176    }
177
178    fn finish(self) -> (String, Value) {
179        let Node { name, attrs, children, text } = self;
180
181        // an element whose children are all <item> is an array
182        if !children.is_empty() && children.iter().all(|(n, _)| n == ARRAY_ITEM) {
183            let items = children.into_iter().map(|(_, v)| v).collect();
184            return (name, Value::Array(items));
185        }
186
187        if children.is_empty() && attrs.is_empty() {
188            let trimmed = text.trim();
189            return (name, infer_scalar(trimmed));
190        }
191
192        let mut map = Map::new();
193        for (key, value) in attrs {
194            map.insert(key, value);
195        }
196        // repeated sibling names collapse into an array
197        for (key, value) in children {
198            match map.get_mut(&key) {
199                Some(Value::Array(existing)) => existing.push(value),
200                Some(slot) => {
201                    let previous = slot.take();
202                    *slot = Value::Array(vec![previous, value]);
203                }
204                None => {
205                    map.insert(key, value);
206                }
207            }
208        }
209        (name, Value::Object(map))
210    }
211}
212
213/// Resolve an entity reference: the five predefined XML entities plus
214/// numeric character references (`&#38;`, `&#x26;`).
215fn resolve_entity(e: &quick_xml::events::BytesRef) -> Result<String, String> {
216    if e.is_char_ref() {
217        return match e.resolve_char_ref() {
218            Ok(Some(c)) => Ok(c.to_string()),
219            Ok(None) => Err("unresolvable character reference".to_string()),
220            Err(err) => Err(format!("invalid character reference: {err}")),
221        };
222    }
223    let name = e.decode().map_err(|err| format!("invalid entity reference: {err}"))?;
224    match name.as_ref() {
225        "amp" => Ok("&".to_string()),
226        "lt" => Ok("<".to_string()),
227        "gt" => Ok(">".to_string()),
228        "quot" => Ok("\"".to_string()),
229        "apos" => Ok("'".to_string()),
230        other => Err(format!(
231            "unknown entity '&{other};' (only the predefined XML entities are supported)"
232        )),
233    }
234}
235
236fn attach(
237    stack: &mut [Node],
238    root: &mut Option<(String, Value)>,
239    name: String,
240    value: Value,
241) -> Result<(), String> {
242    match stack.last_mut() {
243        Some(parent) => {
244            parent.children.push((name, value));
245            Ok(())
246        }
247        None => {
248            if root.is_some() {
249                return Err("XML documents must have a single root element".to_string());
250            }
251            *root = Some((name, value));
252            Ok(())
253        }
254    }
255}
256
257/// Infer a JSON scalar from XML text content.
258fn infer_scalar(text: &str) -> Value {
259    let t = text.trim();
260    if t.is_empty() {
261        return Value::Null;
262    }
263    match t {
264        "true" => return Value::Bool(true),
265        "false" => return Value::Bool(false),
266        "null" => return Value::Null,
267        _ => {}
268    }
269    // only treat as a number when the whole token is numeric, so dates and
270    // codes ("2027-07-17", "C") stay strings
271    if let Ok(i) = t.parse::<i64>() {
272        return Value::Number(i.into());
273    }
274    if let Ok(f) = t.parse::<f64>() {
275        if f.is_finite() {
276            if let Some(n) = serde_json::Number::from_f64(f) {
277                return Value::Number(n);
278            }
279        }
280    }
281    Value::String(t.to_string())
282}
283
284// ── Output ──────────────────────────────────────────────────────────────
285
286/// Render a list of contract results in the requested format.
287///
288/// JSON output is a well-formed array; XML output wraps the results in a
289/// `<results>` document element.
290pub fn render_results(results: &[Value], format: Format) -> String {
291    let mut array = Value::Array(results.to_vec());
292    strip_nulls(&mut array);
293    match format {
294        Format::Json => serde_json::to_string_pretty(&array).unwrap_or_else(|_| "[]".to_string()),
295        Format::Xml => value_to_xml(&array, "results"),
296    }
297}
298
299/// Render a single value in the requested format.
300pub fn render_value(value: &Value, format: Format, root: &str) -> String {
301    let mut value = value.clone();
302    strip_nulls(&mut value);
303    match format {
304        Format::Json => serde_json::to_string_pretty(&value).unwrap_or_default(),
305        Format::Xml => value_to_xml(&value, root),
306    }
307}
308
309/// Drop null object fields recursively.
310///
311/// Every nullable field in the data model is an `Option`, for which an
312/// absent key and an explicit null are equivalent on the way back in, so
313/// this keeps output readable without changing what it means.
314pub fn strip_nulls(value: &mut Value) {
315    match value {
316        Value::Object(map) => {
317            map.retain(|_, v| !v.is_null());
318            for v in map.values_mut() {
319                strip_nulls(v);
320            }
321        }
322        Value::Array(items) => {
323            for item in items {
324                strip_nulls(item);
325            }
326        }
327        _ => {}
328    }
329}
330
331/// Serialize a [`Value`] as an XML document with `root` as the document
332/// element. Arrays are written as `<item>` children, mirroring the input
333/// convention, so output can be fed back in as input.
334pub fn value_to_xml(value: &Value, root: &str) -> String {
335    let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
336    write_element(&mut out, root, value, 0);
337    out
338}
339
340fn write_element(out: &mut String, name: &str, value: &Value, depth: usize) {
341    let pad = "  ".repeat(depth);
342    match value {
343        Value::Null => {
344            let _ = writeln!(out, "{pad}<{name}/>");
345        }
346        Value::Bool(_) | Value::Number(_) | Value::String(_) => {
347            let text = match value {
348                Value::String(s) => escape_text(s),
349                other => other.to_string(),
350            };
351            let _ = writeln!(out, "{pad}<{name}>{text}</{name}>");
352        }
353        Value::Array(items) => {
354            if items.is_empty() {
355                let _ = writeln!(out, "{pad}<{name}/>");
356                return;
357            }
358            let _ = writeln!(out, "{pad}<{name}>");
359            for item in items {
360                write_element(out, ARRAY_ITEM, item, depth + 1);
361            }
362            let _ = writeln!(out, "{pad}</{name}>");
363        }
364        Value::Object(map) => {
365            if map.is_empty() {
366                let _ = writeln!(out, "{pad}<{name}/>");
367                return;
368            }
369            let _ = writeln!(out, "{pad}<{name}>");
370            for (key, child) in map {
371                write_element(out, key, child, depth + 1);
372            }
373            let _ = writeln!(out, "{pad}</{name}>");
374        }
375    }
376}
377
378fn escape_text(text: &str) -> String {
379    let mut out = String::with_capacity(text.len());
380    for c in text.chars() {
381        match c {
382            '&' => out.push_str("&amp;"),
383            '<' => out.push_str("&lt;"),
384            '>' => out.push_str("&gt;"),
385            '"' => out.push_str("&quot;"),
386            '\'' => out.push_str("&apos;"),
387            _ => out.push(c),
388        }
389    }
390    out
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use serde_json::json;
397
398    #[test]
399    fn detects_format_from_content_and_path() {
400        assert_eq!(Format::detect("  { \"a\": 1 }"), Format::Json);
401        assert_eq!(Format::detect("\n<?xml version=\"1.0\"?><a/>"), Format::Xml);
402        assert_eq!(Format::detect("<contracts/>"), Format::Xml);
403        assert_eq!(Format::from_path("in.xml"), Some(Format::Xml));
404        assert_eq!(Format::from_path("in.JSON"), Some(Format::Json));
405        assert_eq!(Format::from_path("in.txt"), None);
406    }
407
408    #[test]
409    fn scalars_are_inferred_without_eating_dates_or_codes() {
410        assert_eq!(infer_scalar("100"), json!(100));
411        assert_eq!(infer_scalar(" 0.30 "), json!(0.30));
412        assert_eq!(infer_scalar("-1.5e-3"), json!(-0.0015));
413        assert_eq!(infer_scalar("true"), json!(true));
414        assert_eq!(infer_scalar(""), Value::Null);
415        // must stay strings
416        assert_eq!(infer_scalar("2027-07-17"), json!("2027-07-17"));
417        assert_eq!(infer_scalar("C"), json!("C"));
418        assert_eq!(infer_scalar("down_out"), json!("down_out"));
419        assert_eq!(infer_scalar("Act365"), json!("Act365"));
420    }
421
422    #[test]
423    fn elements_and_attributes_both_become_fields() {
424        let value = xml_to_value(
425            r#"<curve type="flat"><rate>0.05</rate><day_count>Act365</day_count></curve>"#,
426        )
427        .unwrap();
428        assert_eq!(value, json!({"type": "flat", "rate": 0.05, "day_count": "Act365"}));
429    }
430
431    #[test]
432    fn item_children_make_arrays_including_single_element() {
433        let value = xml_to_value("<tenors><item>0.5</item><item>1.0</item></tenors>").unwrap();
434        assert_eq!(value, json!([0.5, 1.0]));
435        let single = xml_to_value("<tenors><item>0.5</item></tenors>").unwrap();
436        assert_eq!(single, json!([0.5]));
437    }
438
439    #[test]
440    fn nested_arrays_round_trip() {
441        let xml = "<vols><item><item>0.32</item><item>0.30</item></item>\
442                   <item><item>0.33</item><item>0.31</item></item></vols>";
443        assert_eq!(xml_to_value(xml).unwrap(), json!([[0.32, 0.30], [0.33, 0.31]]));
444    }
445
446    #[test]
447    fn repeated_siblings_collapse_into_an_array() {
448        let value = xml_to_value("<root><tag>a</tag><tag>b</tag><other>c</other></root>").unwrap();
449        assert_eq!(value, json!({"tag": ["a", "b"], "other": "c"}));
450    }
451
452    #[test]
453    fn empty_and_self_closing_elements_are_null() {
454        let value = xml_to_value("<root><a/><b></b><c>1</c></root>").unwrap();
455        assert_eq!(value, json!({"a": null, "b": null, "c": 1}));
456    }
457
458    #[test]
459    fn entities_and_cdata_are_decoded() {
460        let value = xml_to_value("<root><a>A &amp; B</a><b><![CDATA[x < y]]></b></root>").unwrap();
461        assert_eq!(value, json!({"a": "A & B", "b": "x < y"}));
462    }
463
464    #[test]
465    fn declaration_and_comments_are_ignored() {
466        let value =
467            xml_to_value("<?xml version=\"1.0\"?><!-- note --><root><a>1</a></root>").unwrap();
468        assert_eq!(value, json!({"a": 1}));
469    }
470
471    #[test]
472    fn malformed_documents_are_reported() {
473        assert!(xml_to_value("<root><a></root>").is_err());
474        assert!(xml_to_value("").is_err());
475        assert!(xml_to_value("not xml at all").is_err());
476    }
477
478    #[test]
479    fn value_to_xml_round_trips_through_the_reader() {
480        let original = json!({
481            "asset": "EQ",
482            "contracts": [
483                {"action": "PV", "strike_price": 100.0, "flag": true, "missing": null},
484                {"action": "PV", "tenors": [0.5, "2028-07-16"], "nested": [[1.0, 2.0]]}
485            ]
486        });
487        let xml = value_to_xml(&original, "root");
488        let back = xml_to_value(&xml).unwrap();
489        assert_eq!(back, original, "\nXML was:\n{xml}");
490    }
491
492    #[test]
493    fn xml_special_characters_survive_a_round_trip() {
494        let original = json!({"name": "Smith & Co <\"AAA\">"});
495        let back = xml_to_value(&value_to_xml(&original, "root")).unwrap();
496        assert_eq!(back, original);
497    }
498
499    #[test]
500    fn null_fields_are_dropped_from_output() {
501        let mut v = json!({"a": 1, "b": null, "c": {"d": null, "e": 2}, "f": [{"g": null}]});
502        strip_nulls(&mut v);
503        assert_eq!(v, json!({"a": 1, "c": {"e": 2}, "f": [{}]}));
504    }
505
506    #[test]
507    fn rendered_output_is_valid_in_both_formats() {
508        let results = vec![json!({"contract": {"action": "PV", "skip": null}, "output": {"pv": 1.5}})];
509        let as_json: Value = serde_json::from_str(&render_results(&results, Format::Json)).unwrap();
510        let as_xml = xml_to_value(&render_results(&results, Format::Xml)).unwrap();
511        assert_eq!(as_json, as_xml, "both formats must carry the same data");
512        assert_eq!(as_json[0]["output"]["pv"], json!(1.5));
513        assert!(as_json[0]["contract"].get("skip").is_none());
514    }
515
516    #[test]
517    fn parse_dispatches_on_format() {
518        #[derive(serde::Deserialize, PartialEq, Debug)]
519        struct Doc {
520            a: i32,
521            b: String,
522        }
523        let from_json: Doc = parse(r#"{"a": 1, "b": "x"}"#, Format::Json).unwrap();
524        let from_xml: Doc = parse("<doc><a>1</a><b>x</b></doc>", Format::Xml).unwrap();
525        assert_eq!(from_json, from_xml);
526    }
527}