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