1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
30#[serde(rename_all = "snake_case")]
31pub enum SimpleStep {
32 Base64,
34 Gunzip,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
40#[serde(deny_unknown_fields)]
41pub struct UnzipSpec {
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub member: Option<String>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
50#[serde(rename_all = "snake_case")]
51pub enum ParseFormat {
52 #[default]
54 Json,
55 Csv,
57 Xlsx,
59 Xml,
61}
62
63fn default_has_headers() -> bool {
64 true
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
69#[serde(deny_unknown_fields)]
70pub struct ParseSpec {
71 pub format: ParseFormat,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub records_path: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub delimiter: Option<u8>,
80 #[serde(default = "default_has_headers")]
82 pub has_headers: bool,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub sheet: Option<String>,
86 #[serde(default)]
88 pub header_row: usize,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
93#[serde(untagged)]
94pub enum DecodeStep {
95 Simple(SimpleStep),
97 Extract {
99 extract: String,
101 },
102 Unzip {
104 unzip: UnzipSpec,
106 },
107 Parse {
109 parse: ParseSpec,
111 },
112}
113
114pub 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 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
177fn 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 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, };
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
267fn 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
283fn 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 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 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 let recs = run_decode(body, &[]).await.unwrap();
487 assert_eq!(recs.len(), 1);
488 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}