Skip to main content

faucet_source_rest/
decode.rs

1//! Response-decode pipeline (#515).
2//!
3//! A declarative chain applied to the raw response **body** before record
4//! extraction, so a source can consume payloads that aren't plain JSON —
5//! including files embedded in a JSON/SOAP envelope:
6//!
7//! ```yaml
8//! decode:
9//!   - extract: "$.d.reportBytes"          # pull a field out of the envelope
10//!   - base64                               # base64 → bytes
11//!   - unzip: { member: "*.csv" }           # or `gunzip`
12//!   - parse: { format: csv, has_headers: true }
13//! ```
14//!
15//! Steps compose left-to-right over a byte buffer; the terminal `parse` step
16//! turns the bytes into records. Without an explicit `parse`, the bytes are
17//! parsed as JSON.
18
19use base64::Engine;
20use faucet_core::FaucetError;
21use jsonpath_rust::JsonPath;
22use quick_xml::events::Event;
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25use serde_json::{Map, Value};
26use std::io::{Cursor, Read};
27
28/// A byte-chain step with no parameters (`- base64`, `- gunzip`).
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
30#[serde(rename_all = "snake_case")]
31pub enum SimpleStep {
32    /// Base64-decode the (UTF-8 text) buffer into bytes.
33    Base64,
34    /// Gzip-decompress the buffer.
35    Gunzip,
36}
37
38/// Select a member of a zip archive.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
40#[serde(deny_unknown_fields)]
41pub struct UnzipSpec {
42    /// Glob (`*.csv`, `prefix*`, `*mid*`, or an exact name) selecting the member.
43    /// When omitted, the first file entry is used.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub member: Option<String>,
46}
47
48/// Final-parse format for a decode chain.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
50#[serde(rename_all = "snake_case")]
51pub enum ParseFormat {
52    /// JSON (default).
53    #[default]
54    Json,
55    /// Delimited text.
56    Csv,
57    /// Excel workbook (requires the crate's `excel` feature).
58    Xlsx,
59    /// XML → JSON.
60    Xml,
61}
62
63fn default_has_headers() -> bool {
64    true
65}
66
67/// Parse the decoded bytes into records.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
69#[serde(deny_unknown_fields)]
70pub struct ParseSpec {
71    /// Output format.
72    pub format: ParseFormat,
73    /// JSONPath selecting the record array (json/xml). When omitted, an array
74    /// body becomes the records and an object becomes a single record.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub records_path: Option<String>,
77    /// CSV delimiter byte (default `,`).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub delimiter: Option<u8>,
80    /// Whether the first CSV row is a header (default `true`).
81    #[serde(default = "default_has_headers")]
82    pub has_headers: bool,
83    /// Excel worksheet name / index-as-string (default: first).
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub sheet: Option<String>,
86    /// 0-based Excel header row (default `0`).
87    #[serde(default)]
88    pub header_row: usize,
89}
90
91/// One step of the decode chain.
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
93#[serde(untagged)]
94pub enum DecodeStep {
95    /// Parameterless byte step (`base64` / `gunzip`).
96    Simple(SimpleStep),
97    /// Pull a (string) field out of a JSON body first.
98    Extract {
99        /// JSONPath to a string field whose value becomes the buffer.
100        extract: String,
101    },
102    /// Select a member from a zip archive.
103    Unzip {
104        /// Member selector.
105        unzip: UnzipSpec,
106    },
107    /// Terminal parse into records.
108    Parse {
109        /// Parse options.
110        parse: ParseSpec,
111    },
112}
113
114/// Run the decode chain over the response body, returning records.
115pub async fn run_decode(body: &[u8], steps: &[DecodeStep]) -> Result<Vec<Value>, FaucetError> {
116    let mut buf = body.to_vec();
117    for step in steps {
118        match step {
119            DecodeStep::Extract { extract } => {
120                let v: Value = serde_json::from_slice(&buf).map_err(|e| {
121                    FaucetError::Source(format!("decode `extract`: body is not JSON: {e}"))
122                })?;
123                let s = jsonpath_first_string(&v, extract).ok_or_else(|| {
124                    FaucetError::Source(format!(
125                        "decode `extract`: '{extract}' matched no string field"
126                    ))
127                })?;
128                buf = s.into_bytes();
129            }
130            DecodeStep::Simple(SimpleStep::Base64) => {
131                let text = std::str::from_utf8(&buf).map_err(|e| {
132                    FaucetError::Source(format!("decode `base64`: buffer is not UTF-8 text: {e}"))
133                })?;
134                buf = base64::engine::general_purpose::STANDARD
135                    .decode(text.trim())
136                    .map_err(|e| FaucetError::Source(format!("decode `base64`: {e}")))?;
137            }
138            DecodeStep::Simple(SimpleStep::Gunzip) => {
139                let mut out = Vec::new();
140                flate2::read::GzDecoder::new(Cursor::new(&buf))
141                    .read_to_end(&mut out)
142                    .map_err(|e| FaucetError::Source(format!("decode `gunzip`: {e}")))?;
143                buf = out;
144            }
145            DecodeStep::Unzip { unzip } => {
146                buf = unzip_member(&buf, unzip.member.as_deref())?;
147            }
148            DecodeStep::Parse { parse } => {
149                return parse_records(&buf, parse).await;
150            }
151        }
152    }
153    // No explicit `parse` → default to JSON.
154    parse_records(
155        &buf,
156        &ParseSpec {
157            format: ParseFormat::Json,
158            records_path: None,
159            delimiter: None,
160            has_headers: true,
161            sheet: None,
162            header_row: 0,
163        },
164    )
165    .await
166}
167
168fn jsonpath_first_string(v: &Value, path: &str) -> Option<String> {
169    let results = v.query(path).ok()?;
170    match results.first()? {
171        Value::String(s) => Some(s.clone()),
172        Value::Number(n) => Some(n.to_string()),
173        _ => None,
174    }
175}
176
177/// Minimal glob: `*` matches any run. Supports `*.ext`, `prefix*`, `*mid*`,
178/// `*a*b*`, and exact names.
179fn glob_match(pattern: &str, name: &str) -> bool {
180    if !pattern.contains('*') {
181        return pattern == name;
182    }
183    let parts: Vec<&str> = pattern.split('*').collect();
184    let mut pos = 0usize;
185    for (i, part) in parts.iter().enumerate() {
186        if part.is_empty() {
187            continue;
188        }
189        if i == 0 {
190            if !name[pos..].starts_with(part) {
191                return false;
192            }
193            pos += part.len();
194        } else if i == parts.len() - 1 {
195            return name[pos..].ends_with(part);
196        } else {
197            match name[pos..].find(part) {
198                Some(idx) => pos += idx + part.len(),
199                None => return false,
200            }
201        }
202    }
203    true
204}
205
206fn unzip_member(bytes: &[u8], member: Option<&str>) -> Result<Vec<u8>, FaucetError> {
207    let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
208        .map_err(|e| FaucetError::Source(format!("decode `unzip`: not a valid zip: {e}")))?;
209    // Resolve the member index first (immutable name scan), then read it.
210    let mut chosen: Option<usize> = None;
211    for i in 0..archive.len() {
212        let f = archive
213            .by_index(i)
214            .map_err(|e| FaucetError::Source(format!("decode `unzip`: {e}")))?;
215        if !f.is_file() {
216            continue;
217        }
218        let matches = match member {
219            Some(pat) => glob_match(pat, f.name()),
220            None => true, // first file
221        };
222        if matches {
223            chosen = Some(i);
224            break;
225        }
226    }
227    let idx = chosen.ok_or_else(|| {
228        FaucetError::Source(format!(
229            "decode `unzip`: no member matched {}",
230            member
231                .map(|m| format!("'{m}'"))
232                .unwrap_or_else(|| "any".into())
233        ))
234    })?;
235    let mut f = archive
236        .by_index(idx)
237        .map_err(|e| FaucetError::Source(format!("decode `unzip`: {e}")))?;
238    let mut out = Vec::new();
239    f.read_to_end(&mut out)
240        .map_err(|e| FaucetError::Source(format!("decode `unzip`: reading member: {e}")))?;
241    Ok(out)
242}
243
244async fn parse_records(bytes: &[u8], spec: &ParseSpec) -> Result<Vec<Value>, FaucetError> {
245    match spec.format {
246        ParseFormat::Json => {
247            let v: Value = serde_json::from_slice(bytes)
248                .map_err(|e| FaucetError::Source(format!("decode `parse` json: {e}")))?;
249            Ok(records_from_value(v, spec.records_path.as_deref()))
250        }
251        ParseFormat::Csv => {
252            crate::format::parse_csv(bytes, spec.delimiter.unwrap_or(b','), spec.has_headers)
253                .await
254                .map_err(|e| FaucetError::Source(format!("decode `parse` csv: {e}")))
255        }
256        ParseFormat::Xlsx => {
257            crate::format::parse_excel(bytes, spec.sheet.as_deref(), spec.header_row)
258                .map_err(|e| FaucetError::Source(format!("decode `parse` xlsx: {e}")))
259        }
260        ParseFormat::Xml => {
261            let v = xml_to_json(bytes)?;
262            Ok(records_from_value(v, spec.records_path.as_deref()))
263        }
264    }
265}
266
267/// Turn a JSON value into records: apply `records_path` if given, else an array
268/// becomes the records and any other value becomes a single record.
269fn records_from_value(v: Value, records_path: Option<&str>) -> Vec<Value> {
270    match records_path {
271        Some(path) => v
272            .query(path)
273            .ok()
274            .map(|ms| ms.into_iter().cloned().collect())
275            .unwrap_or_default(),
276        None => match v {
277            Value::Array(a) => a,
278            other => vec![other],
279        },
280    }
281}
282
283/// Compact XML → JSON: each element becomes an object of its children; repeated
284/// child tags become arrays; attributes are `@name`; text is `#text` (or the
285/// value directly when an element has only text).
286fn xml_to_json(bytes: &[u8]) -> Result<Value, FaucetError> {
287    let text = std::str::from_utf8(bytes)
288        .map_err(|e| FaucetError::Source(format!("decode `parse` xml: not UTF-8: {e}")))?;
289    let mut reader = quick_xml::Reader::from_str(text);
290    // A stack of (object, text-accumulator) frames; index 0 is the document root.
291    let mut stack: Vec<(Map<String, Value>, String)> = vec![(Map::new(), String::new())];
292
293    fn attrs(e: &quick_xml::events::BytesStart) -> Map<String, Value> {
294        let mut m = Map::new();
295        for a in e.attributes().flatten() {
296            let k = String::from_utf8_lossy(a.key.as_ref())
297                .rsplit(':')
298                .next()
299                .unwrap_or_default()
300                .to_string();
301            if let Ok(v) = a.unescape_value() {
302                m.insert(format!("@{k}"), Value::String(v.to_string()));
303            }
304        }
305        m
306    }
307    fn local(name: &[u8]) -> String {
308        String::from_utf8_lossy(name)
309            .rsplit(':')
310            .next()
311            .unwrap_or_default()
312            .to_string()
313    }
314    fn insert_child(parent: &mut Map<String, Value>, key: String, val: Value) {
315        match parent.get_mut(&key) {
316            Some(Value::Array(arr)) => arr.push(val),
317            Some(existing) => {
318                let prev = existing.take();
319                parent.insert(key, Value::Array(vec![prev, val]));
320            }
321            None => {
322                parent.insert(key, val);
323            }
324        }
325    }
326    fn finish(obj: Map<String, Value>, text: String) -> Value {
327        let trimmed = text.trim();
328        if obj.is_empty() {
329            Value::String(trimmed.to_string())
330        } else {
331            let mut obj = obj;
332            if !trimmed.is_empty() {
333                obj.insert("#text".to_string(), Value::String(trimmed.to_string()));
334            }
335            Value::Object(obj)
336        }
337    }
338
339    loop {
340        match reader
341            .read_event()
342            .map_err(|e| FaucetError::Source(format!("decode `parse` xml: {e}")))?
343        {
344            Event::Eof => break,
345            Event::Start(e) => stack.push((attrs(&e), String::new())),
346            Event::Empty(e) => {
347                let name = local(e.name().as_ref());
348                let val = finish(attrs(&e), String::new());
349                let top = stack.last_mut().expect("root frame present");
350                insert_child(&mut top.0, name, val);
351            }
352            Event::End(e) => {
353                let (obj, text) = stack.pop().expect("matched start frame");
354                let name = local(e.name().as_ref());
355                let val = finish(obj, text);
356                let top = stack.last_mut().expect("root frame present");
357                insert_child(&mut top.0, name, val);
358            }
359            Event::Text(t) => {
360                if let Ok(s) = t.unescape() {
361                    stack.last_mut().expect("root frame present").1.push_str(&s);
362                }
363            }
364            Event::CData(t) => {
365                stack
366                    .last_mut()
367                    .expect("root frame present")
368                    .1
369                    .push_str(&String::from_utf8_lossy(&t));
370            }
371            _ => {}
372        }
373    }
374    let (root, _) = stack.pop().unwrap_or_default();
375    Ok(Value::Object(root))
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use serde_json::json;
382
383    fn parse_json() -> ParseSpec {
384        ParseSpec {
385            format: ParseFormat::Json,
386            records_path: None,
387            delimiter: None,
388            has_headers: true,
389            sheet: None,
390            header_row: 0,
391        }
392    }
393
394    #[test]
395    fn glob_matches_common_patterns() {
396        assert!(glob_match("*.csv", "report.csv"));
397        assert!(!glob_match("*.csv", "report.txt"));
398        assert!(glob_match("data*", "data_2024.csv"));
399        assert!(glob_match("*part*", "x_part_1"));
400        assert!(glob_match("exact.csv", "exact.csv"));
401        assert!(!glob_match("exact.csv", "other.csv"));
402        assert!(glob_match("*a*b*", "xxaxxbxx"));
403        assert!(!glob_match("*a*b*", "xxbxxaxx"));
404    }
405
406    #[tokio::test]
407    async fn extract_base64_json_chain() {
408        // A JSON envelope holding a base64-encoded JSON array.
409        let inner = br#"[{"id":1},{"id":2}]"#;
410        let b64 = base64::engine::general_purpose::STANDARD.encode(inner);
411        let body = json!({ "d": { "payload": b64 } }).to_string();
412        let steps = vec![
413            DecodeStep::Extract {
414                extract: "$.d.payload".into(),
415            },
416            DecodeStep::Simple(SimpleStep::Base64),
417            DecodeStep::Parse {
418                parse: parse_json(),
419            },
420        ];
421        let recs = run_decode(body.as_bytes(), &steps).await.unwrap();
422        assert_eq!(recs.len(), 2);
423        assert_eq!(recs[1]["id"], 2);
424    }
425
426    #[tokio::test]
427    async fn gunzip_csv_chain() {
428        use flate2::{Compression, write::GzEncoder};
429        use std::io::Write;
430        let csv = b"a,b\n1,2\n3,4\n";
431        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
432        enc.write_all(csv).unwrap();
433        let gz = enc.finish().unwrap();
434        let steps = vec![
435            DecodeStep::Simple(SimpleStep::Gunzip),
436            DecodeStep::Parse {
437                parse: ParseSpec {
438                    format: ParseFormat::Csv,
439                    ..parse_json()
440                },
441            },
442        ];
443        let recs = run_decode(&gz, &steps).await.unwrap();
444        assert_eq!(recs.len(), 2);
445        assert_eq!(recs[0]["a"], "1");
446        assert_eq!(recs[1]["b"], "4");
447    }
448
449    #[tokio::test]
450    async fn unzip_member_glob_chain() {
451        use std::io::Write;
452        use zip::write::SimpleFileOptions;
453        let mut cur = Cursor::new(Vec::new());
454        {
455            let mut zw = zip::ZipWriter::new(&mut cur);
456            let opts = SimpleFileOptions::default();
457            zw.start_file("notes.txt", opts).unwrap();
458            zw.write_all(b"ignore").unwrap();
459            zw.start_file("data.csv", opts).unwrap();
460            zw.write_all(b"x\n9\n").unwrap();
461            zw.finish().unwrap();
462        }
463        let zip_bytes = cur.into_inner();
464        let steps = vec![
465            DecodeStep::Unzip {
466                unzip: UnzipSpec {
467                    member: Some("*.csv".into()),
468                },
469            },
470            DecodeStep::Parse {
471                parse: ParseSpec {
472                    format: ParseFormat::Csv,
473                    ..parse_json()
474                },
475            },
476        ];
477        let recs = run_decode(&zip_bytes, &steps).await.unwrap();
478        assert_eq!(recs.len(), 1);
479        assert_eq!(recs[0]["x"], "9");
480    }
481
482    #[tokio::test]
483    async fn default_parse_is_json_and_records_path_works() {
484        let body = br#"{"items":[{"n":1},{"n":2}]}"#;
485        // No parse step → default json, whole object is one record.
486        let recs = run_decode(body, &[]).await.unwrap();
487        assert_eq!(recs.len(), 1);
488        // With records_path → the array.
489        let steps = vec![DecodeStep::Parse {
490            parse: ParseSpec {
491                records_path: Some("$.items[*]".into()),
492                ..parse_json()
493            },
494        }];
495        let recs = run_decode(body, &steps).await.unwrap();
496        assert_eq!(recs.len(), 2);
497    }
498
499    #[test]
500    fn xml_to_json_handles_repeated_elements_and_attrs() {
501        let xml = br#"<root><row id="1"><n>a</n></row><row id="2"><n>b</n></row></root>"#;
502        let v = xml_to_json(xml).unwrap();
503        let rows = &v["root"]["row"];
504        assert!(rows.is_array());
505        assert_eq!(rows[0]["@id"], "1");
506        assert_eq!(rows[0]["n"], "a");
507        assert_eq!(rows[1]["n"], "b");
508    }
509
510    #[tokio::test]
511    async fn xml_parse_with_records_path() {
512        let xml = br#"<root><row><n>a</n></row><row><n>b</n></row></root>"#;
513        let steps = vec![DecodeStep::Parse {
514            parse: ParseSpec {
515                format: ParseFormat::Xml,
516                records_path: Some("$.root.row[*]".into()),
517                ..parse_json()
518            },
519        }];
520        let recs = run_decode(xml, &steps).await.unwrap();
521        assert_eq!(recs.len(), 2);
522        assert_eq!(recs[1]["n"], "b");
523    }
524
525    #[tokio::test]
526    async fn base64_on_non_utf8_errors() {
527        let steps = vec![DecodeStep::Simple(SimpleStep::Base64)];
528        assert!(run_decode(&[0xff, 0xfe], &steps).await.is_err());
529    }
530
531    #[tokio::test]
532    async fn extract_missing_field_errors() {
533        let steps = vec![DecodeStep::Extract {
534            extract: "$.nope".into(),
535        }];
536        assert!(run_decode(br#"{"a":1}"#, &steps).await.is_err());
537    }
538
539    #[tokio::test]
540    async fn unzip_no_match_errors() {
541        use std::io::Write;
542        use zip::write::SimpleFileOptions;
543        let mut cur = Cursor::new(Vec::new());
544        {
545            let mut zw = zip::ZipWriter::new(&mut cur);
546            zw.start_file("a.txt", SimpleFileOptions::default())
547                .unwrap();
548            zw.write_all(b"x").unwrap();
549            zw.finish().unwrap();
550        }
551        let steps = vec![DecodeStep::Unzip {
552            unzip: UnzipSpec {
553                member: Some("*.csv".into()),
554            },
555        }];
556        assert!(run_decode(&cur.into_inner(), &steps).await.is_err());
557    }
558
559    #[test]
560    fn decode_step_deserializes_mixed_forms() {
561        let steps: Vec<DecodeStep> = serde_json::from_value(json!([
562            "base64",
563            "gunzip",
564            { "extract": "$.x" },
565            { "unzip": { "member": "*.csv" } },
566            { "parse": { "format": "csv" } }
567        ]))
568        .unwrap();
569        assert_eq!(steps.len(), 5);
570        assert!(matches!(steps[0], DecodeStep::Simple(SimpleStep::Base64)));
571        assert!(matches!(steps[1], DecodeStep::Simple(SimpleStep::Gunzip)));
572        assert!(matches!(steps[2], DecodeStep::Extract { .. }));
573        assert!(matches!(steps[3], DecodeStep::Unzip { .. }));
574        assert!(matches!(steps[4], DecodeStep::Parse { .. }));
575    }
576}