1use base64::Engine;
24use faucet_core::FaucetError;
25use jsonpath_rust::JsonPath;
26use quick_xml::events::Event;
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use serde_json::{Map, Value};
30use std::io::{Cursor, Read};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
34#[serde(rename_all = "snake_case")]
35pub enum SimpleStep {
36 Base64,
38 Gunzip,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
44#[serde(deny_unknown_fields)]
45pub struct UnzipSpec {
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub member: Option<String>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
54#[serde(rename_all = "snake_case")]
55pub enum ParseFormat {
56 #[default]
58 Json,
59 Csv,
61 Xlsx,
63 Xml,
65}
66
67fn default_has_headers() -> bool {
68 true
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
73#[serde(deny_unknown_fields)]
74pub struct ParseSpec {
75 pub format: ParseFormat,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub records_path: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub delimiter: Option<u8>,
84 #[serde(default = "default_has_headers")]
86 pub has_headers: bool,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub sheet: Option<String>,
90 #[serde(default)]
92 pub header_row: usize,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
97#[serde(untagged)]
98pub enum DecodeStep {
99 Simple(SimpleStep),
101 Extract {
103 extract: String,
105 },
106 Unzip {
108 unzip: UnzipSpec,
110 },
111 Parse {
113 parse: ParseSpec,
115 },
116}
117
118pub async fn run_decode(body: &[u8], steps: &[DecodeStep]) -> Result<Vec<Value>, FaucetError> {
120 let mut buf = body.to_vec();
121 for step in steps {
122 match step {
123 DecodeStep::Extract { extract } => {
124 let s = xml_extract_text(&buf, extract).ok_or_else(|| {
129 FaucetError::Source(format!(
130 "decode `extract`: '{extract}' matched no element text in the XML body"
131 ))
132 })?;
133 buf = s.into_bytes();
134 }
135 DecodeStep::Simple(SimpleStep::Base64) => {
136 let text = std::str::from_utf8(&buf).map_err(|e| {
137 FaucetError::Source(format!("decode `base64`: buffer is not UTF-8 text: {e}"))
138 })?;
139 buf = base64::engine::general_purpose::STANDARD
140 .decode(text.trim())
141 .map_err(|e| FaucetError::Source(format!("decode `base64`: {e}")))?;
142 }
143 DecodeStep::Simple(SimpleStep::Gunzip) => {
144 let mut out = Vec::new();
145 flate2::read::GzDecoder::new(Cursor::new(&buf))
146 .read_to_end(&mut out)
147 .map_err(|e| FaucetError::Source(format!("decode `gunzip`: {e}")))?;
148 buf = out;
149 }
150 DecodeStep::Unzip { unzip } => {
151 buf = unzip_member(&buf, unzip.member.as_deref())?;
152 }
153 DecodeStep::Parse { parse } => {
154 return parse_records(&buf, parse).await;
155 }
156 }
157 }
158 parse_records(
160 &buf,
161 &ParseSpec {
162 format: ParseFormat::Json,
163 records_path: None,
164 delimiter: None,
165 has_headers: true,
166 sheet: None,
167 header_row: 0,
168 },
169 )
170 .await
171}
172
173pub(crate) fn xml_extract_text(bytes: &[u8], dot_path: &str) -> Option<String> {
178 let want: Vec<String> = dot_path
179 .split('.')
180 .filter(|s| !s.is_empty())
181 .map(|s| s.rsplit(':').next().unwrap_or(s).to_string())
182 .collect();
183 if want.is_empty() {
184 return None;
185 }
186 let text = std::str::from_utf8(bytes).ok()?;
187 let mut reader = quick_xml::Reader::from_str(text);
188 let local = |name: &[u8]| -> String {
189 String::from_utf8_lossy(name)
190 .rsplit(':')
191 .next()
192 .unwrap_or_default()
193 .to_string()
194 };
195 let ends_with = |stack: &[String]| -> bool {
196 stack.len() >= want.len() && stack[stack.len() - want.len()..] == want[..]
197 };
198 let mut stack: Vec<String> = Vec::new();
199 let mut capture_depth: Option<usize> = None;
200 let mut out = String::new();
201 loop {
202 match reader.read_event().ok()? {
203 Event::Eof => break,
204 Event::Start(e) => {
205 stack.push(local(e.name().as_ref()));
206 if capture_depth.is_none() && ends_with(&stack) {
207 capture_depth = Some(stack.len());
208 out.clear();
209 }
210 }
211 Event::Empty(e) => {
212 stack.push(local(e.name().as_ref()));
213 if capture_depth.is_none() && ends_with(&stack) {
214 return Some(String::new());
215 }
216 stack.pop();
217 }
218 Event::End(_) => {
219 if capture_depth == Some(stack.len()) {
220 return Some(out);
221 }
222 stack.pop();
223 }
224 Event::Text(t) if capture_depth.is_some() => {
225 if let Ok(s) = t.unescape() {
226 out.push_str(&s);
227 }
228 }
229 Event::CData(t) if capture_depth.is_some() => {
230 out.push_str(&String::from_utf8_lossy(&t));
231 }
232 _ => {}
233 }
234 }
235 None
236}
237
238fn glob_match(pattern: &str, name: &str) -> bool {
241 if !pattern.contains('*') {
242 return pattern == name;
243 }
244 let parts: Vec<&str> = pattern.split('*').collect();
245 let mut pos = 0usize;
246 for (i, part) in parts.iter().enumerate() {
247 if part.is_empty() {
248 continue;
249 }
250 if i == 0 {
251 if !name[pos..].starts_with(part) {
252 return false;
253 }
254 pos += part.len();
255 } else if i == parts.len() - 1 {
256 return name[pos..].ends_with(part);
257 } else {
258 match name[pos..].find(part) {
259 Some(idx) => pos += idx + part.len(),
260 None => return false,
261 }
262 }
263 }
264 true
265}
266
267fn unzip_member(bytes: &[u8], member: Option<&str>) -> Result<Vec<u8>, FaucetError> {
268 let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
269 .map_err(|e| FaucetError::Source(format!("decode `unzip`: not a valid zip: {e}")))?;
270 let mut chosen: Option<usize> = None;
272 for i in 0..archive.len() {
273 let f = archive
274 .by_index(i)
275 .map_err(|e| FaucetError::Source(format!("decode `unzip`: {e}")))?;
276 if !f.is_file() {
277 continue;
278 }
279 let matches = match member {
280 Some(pat) => glob_match(pat, f.name()),
281 None => true, };
283 if matches {
284 chosen = Some(i);
285 break;
286 }
287 }
288 let idx = chosen.ok_or_else(|| {
289 FaucetError::Source(format!(
290 "decode `unzip`: no member matched {}",
291 member
292 .map(|m| format!("'{m}'"))
293 .unwrap_or_else(|| "any".into())
294 ))
295 })?;
296 let mut f = archive
297 .by_index(idx)
298 .map_err(|e| FaucetError::Source(format!("decode `unzip`: {e}")))?;
299 let mut out = Vec::new();
300 f.read_to_end(&mut out)
301 .map_err(|e| FaucetError::Source(format!("decode `unzip`: reading member: {e}")))?;
302 Ok(out)
303}
304
305async fn parse_records(bytes: &[u8], spec: &ParseSpec) -> Result<Vec<Value>, FaucetError> {
306 match spec.format {
307 ParseFormat::Json => {
308 let v: Value = serde_json::from_slice(bytes)
309 .map_err(|e| FaucetError::Source(format!("decode `parse` json: {e}")))?;
310 Ok(records_from_value(v, spec.records_path.as_deref()))
311 }
312 ParseFormat::Csv => {
313 crate::format::parse_csv(bytes, spec.delimiter.unwrap_or(b','), spec.has_headers)
314 .await
315 .map_err(|e| FaucetError::Source(format!("decode `parse` csv: {e}")))
316 }
317 ParseFormat::Xlsx => {
318 crate::format::parse_excel(bytes, spec.sheet.as_deref(), spec.header_row)
319 .map_err(|e| FaucetError::Source(format!("decode `parse` xlsx: {e}")))
320 }
321 ParseFormat::Xml => {
322 let v = xml_to_json(bytes)?;
323 Ok(records_from_value(v, spec.records_path.as_deref()))
324 }
325 }
326}
327
328fn records_from_value(v: Value, records_path: Option<&str>) -> Vec<Value> {
331 match records_path {
332 Some(path) => v
333 .query(path)
334 .ok()
335 .map(|ms| ms.into_iter().cloned().collect())
336 .unwrap_or_default(),
337 None => match v {
338 Value::Array(a) => a,
339 other => vec![other],
340 },
341 }
342}
343
344fn xml_to_json(bytes: &[u8]) -> Result<Value, FaucetError> {
348 let text = std::str::from_utf8(bytes)
349 .map_err(|e| FaucetError::Source(format!("decode `parse` xml: not UTF-8: {e}")))?;
350 let mut reader = quick_xml::Reader::from_str(text);
351 let mut stack: Vec<(Map<String, Value>, String)> = vec![(Map::new(), String::new())];
353
354 fn attrs(e: &quick_xml::events::BytesStart) -> Map<String, Value> {
355 let mut m = Map::new();
356 for a in e.attributes().flatten() {
357 let k = String::from_utf8_lossy(a.key.as_ref())
358 .rsplit(':')
359 .next()
360 .unwrap_or_default()
361 .to_string();
362 if let Ok(v) = a.unescape_value() {
363 m.insert(format!("@{k}"), Value::String(v.to_string()));
364 }
365 }
366 m
367 }
368 fn local(name: &[u8]) -> String {
369 String::from_utf8_lossy(name)
370 .rsplit(':')
371 .next()
372 .unwrap_or_default()
373 .to_string()
374 }
375 fn insert_child(parent: &mut Map<String, Value>, key: String, val: Value) {
376 match parent.get_mut(&key) {
377 Some(Value::Array(arr)) => arr.push(val),
378 Some(existing) => {
379 let prev = existing.take();
380 parent.insert(key, Value::Array(vec![prev, val]));
381 }
382 None => {
383 parent.insert(key, val);
384 }
385 }
386 }
387 fn finish(obj: Map<String, Value>, text: String) -> Value {
388 let trimmed = text.trim();
389 if obj.is_empty() {
390 Value::String(trimmed.to_string())
391 } else {
392 let mut obj = obj;
393 if !trimmed.is_empty() {
394 obj.insert("#text".to_string(), Value::String(trimmed.to_string()));
395 }
396 Value::Object(obj)
397 }
398 }
399
400 loop {
401 match reader
402 .read_event()
403 .map_err(|e| FaucetError::Source(format!("decode `parse` xml: {e}")))?
404 {
405 Event::Eof => break,
406 Event::Start(e) => stack.push((attrs(&e), String::new())),
407 Event::Empty(e) => {
408 let name = local(e.name().as_ref());
409 let val = finish(attrs(&e), String::new());
410 let top = stack.last_mut().expect("root frame present");
411 insert_child(&mut top.0, name, val);
412 }
413 Event::End(e) => {
414 let (obj, text) = stack.pop().expect("matched start frame");
415 let name = local(e.name().as_ref());
416 let val = finish(obj, text);
417 let top = stack.last_mut().expect("root frame present");
418 insert_child(&mut top.0, name, val);
419 }
420 Event::Text(t) => {
421 if let Ok(s) = t.unescape() {
422 stack.last_mut().expect("root frame present").1.push_str(&s);
423 }
424 }
425 Event::CData(t) => {
426 stack
427 .last_mut()
428 .expect("root frame present")
429 .1
430 .push_str(&String::from_utf8_lossy(&t));
431 }
432 _ => {}
433 }
434 }
435 let (root, _) = stack.pop().unwrap_or_default();
436 Ok(Value::Object(root))
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use serde_json::json;
443
444 fn parse_json() -> ParseSpec {
445 ParseSpec {
446 format: ParseFormat::Json,
447 records_path: None,
448 delimiter: None,
449 has_headers: true,
450 sheet: None,
451 header_row: 0,
452 }
453 }
454
455 #[test]
456 fn glob_matches_common_patterns() {
457 assert!(glob_match("*.csv", "report.csv"));
458 assert!(!glob_match("*.csv", "report.txt"));
459 assert!(glob_match("data*", "data_2024.csv"));
460 assert!(glob_match("*part*", "x_part_1"));
461 assert!(glob_match("exact.csv", "exact.csv"));
462 assert!(!glob_match("exact.csv", "other.csv"));
463 assert!(glob_match("*a*b*", "xxaxxbxx"));
464 assert!(!glob_match("*a*b*", "xxbxxaxx"));
465 }
466
467 #[tokio::test]
468 async fn extract_xml_base64_json_chain() {
469 let inner = br#"[{"id":1},{"id":2}]"#;
472 let b64 = base64::engine::general_purpose::STANDARD.encode(inner);
473 let body = format!("<Envelope><Body><d><payload>{b64}</payload></d></Body></Envelope>");
474 let steps = vec![
475 DecodeStep::Extract {
476 extract: "d.payload".into(),
477 },
478 DecodeStep::Simple(SimpleStep::Base64),
479 DecodeStep::Parse {
480 parse: parse_json(),
481 },
482 ];
483 let recs = run_decode(body.as_bytes(), &steps).await.unwrap();
484 assert_eq!(recs.len(), 2);
485 assert_eq!(recs[1]["id"], 2);
486 }
487
488 #[tokio::test]
489 async fn extract_xml_namespaced_trailing_match() {
490 let body = "<soap:Envelope><soap:Body><ns:runReportResponse>\
493 <ns:runReportReturn><ns:reportBytes>SGk=</ns:reportBytes>\
494 </ns:runReportReturn></ns:runReportResponse></soap:Body></soap:Envelope>";
495 let steps = [
498 DecodeStep::Extract {
499 extract: "runReportResponse.runReportReturn.reportBytes".into(),
500 },
501 DecodeStep::Simple(SimpleStep::Base64),
502 DecodeStep::Parse {
503 parse: ParseSpec {
504 format: ParseFormat::Csv,
505 has_headers: false,
506 ..parse_json()
507 },
508 },
509 ];
510 let recs = run_decode(body.as_bytes(), &steps).await.unwrap();
511 assert_eq!(recs.len(), 1);
512 assert_eq!(recs[0]["column_0"], "Hi");
513 }
514
515 #[tokio::test]
516 async fn gunzip_csv_chain() {
517 use flate2::{Compression, write::GzEncoder};
518 use std::io::Write;
519 let csv = b"a,b\n1,2\n3,4\n";
520 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
521 enc.write_all(csv).unwrap();
522 let gz = enc.finish().unwrap();
523 let steps = vec![
524 DecodeStep::Simple(SimpleStep::Gunzip),
525 DecodeStep::Parse {
526 parse: ParseSpec {
527 format: ParseFormat::Csv,
528 ..parse_json()
529 },
530 },
531 ];
532 let recs = run_decode(&gz, &steps).await.unwrap();
533 assert_eq!(recs.len(), 2);
534 assert_eq!(recs[0]["a"], "1");
535 assert_eq!(recs[1]["b"], "4");
536 }
537
538 #[tokio::test]
539 async fn unzip_member_glob_chain() {
540 use std::io::Write;
541 use zip::write::SimpleFileOptions;
542 let mut cur = Cursor::new(Vec::new());
543 {
544 let mut zw = zip::ZipWriter::new(&mut cur);
545 let opts = SimpleFileOptions::default();
546 zw.start_file("notes.txt", opts).unwrap();
547 zw.write_all(b"ignore").unwrap();
548 zw.start_file("data.csv", opts).unwrap();
549 zw.write_all(b"x\n9\n").unwrap();
550 zw.finish().unwrap();
551 }
552 let zip_bytes = cur.into_inner();
553 let steps = vec![
554 DecodeStep::Unzip {
555 unzip: UnzipSpec {
556 member: Some("*.csv".into()),
557 },
558 },
559 DecodeStep::Parse {
560 parse: ParseSpec {
561 format: ParseFormat::Csv,
562 ..parse_json()
563 },
564 },
565 ];
566 let recs = run_decode(&zip_bytes, &steps).await.unwrap();
567 assert_eq!(recs.len(), 1);
568 assert_eq!(recs[0]["x"], "9");
569 }
570
571 #[tokio::test]
572 async fn default_parse_is_json_and_records_path_works() {
573 let body = br#"{"items":[{"n":1},{"n":2}]}"#;
574 let recs = run_decode(body, &[]).await.unwrap();
576 assert_eq!(recs.len(), 1);
577 let steps = vec![DecodeStep::Parse {
579 parse: ParseSpec {
580 records_path: Some("$.items[*]".into()),
581 ..parse_json()
582 },
583 }];
584 let recs = run_decode(body, &steps).await.unwrap();
585 assert_eq!(recs.len(), 2);
586 }
587
588 #[test]
589 fn xml_to_json_handles_repeated_elements_and_attrs() {
590 let xml = br#"<root><row id="1"><n>a</n></row><row id="2"><n>b</n></row></root>"#;
591 let v = xml_to_json(xml).unwrap();
592 let rows = &v["root"]["row"];
593 assert!(rows.is_array());
594 assert_eq!(rows[0]["@id"], "1");
595 assert_eq!(rows[0]["n"], "a");
596 assert_eq!(rows[1]["n"], "b");
597 }
598
599 #[tokio::test]
600 async fn xml_parse_with_records_path() {
601 let xml = br#"<root><row><n>a</n></row><row><n>b</n></row></root>"#;
602 let steps = vec![DecodeStep::Parse {
603 parse: ParseSpec {
604 format: ParseFormat::Xml,
605 records_path: Some("$.root.row[*]".into()),
606 ..parse_json()
607 },
608 }];
609 let recs = run_decode(xml, &steps).await.unwrap();
610 assert_eq!(recs.len(), 2);
611 assert_eq!(recs[1]["n"], "b");
612 }
613
614 #[tokio::test]
615 async fn base64_on_non_utf8_errors() {
616 let steps = vec![DecodeStep::Simple(SimpleStep::Base64)];
617 assert!(run_decode(&[0xff, 0xfe], &steps).await.is_err());
618 }
619
620 #[tokio::test]
621 async fn extract_missing_field_errors() {
622 let steps = vec![DecodeStep::Extract {
623 extract: "$.nope".into(),
624 }];
625 assert!(run_decode(br#"{"a":1}"#, &steps).await.is_err());
626 }
627
628 #[tokio::test]
629 async fn unzip_no_match_errors() {
630 use std::io::Write;
631 use zip::write::SimpleFileOptions;
632 let mut cur = Cursor::new(Vec::new());
633 {
634 let mut zw = zip::ZipWriter::new(&mut cur);
635 zw.start_file("a.txt", SimpleFileOptions::default())
636 .unwrap();
637 zw.write_all(b"x").unwrap();
638 zw.finish().unwrap();
639 }
640 let steps = vec![DecodeStep::Unzip {
641 unzip: UnzipSpec {
642 member: Some("*.csv".into()),
643 },
644 }];
645 assert!(run_decode(&cur.into_inner(), &steps).await.is_err());
646 }
647
648 #[test]
649 fn decode_step_deserializes_mixed_forms() {
650 let steps: Vec<DecodeStep> = serde_json::from_value(json!([
651 "base64",
652 "gunzip",
653 { "extract": "$.x" },
654 { "unzip": { "member": "*.csv" } },
655 { "parse": { "format": "csv" } }
656 ]))
657 .unwrap();
658 assert_eq!(steps.len(), 5);
659 assert!(matches!(steps[0], DecodeStep::Simple(SimpleStep::Base64)));
660 assert!(matches!(steps[1], DecodeStep::Simple(SimpleStep::Gunzip)));
661 assert!(matches!(steps[2], DecodeStep::Extract { .. }));
662 assert!(matches!(steps[3], DecodeStep::Unzip { .. }));
663 assert!(matches!(steps[4], DecodeStep::Parse { .. }));
664 }
665}