pub mod jsonpath;
pub use jsonpath::extract_records;
use crate::config::RecordsMultiSpec;
use faucet_core::FaucetError;
use jsonpath_rust::JsonPath;
use serde_json::Value;
use std::collections::HashMap;
pub fn extract_configured(
body: &Value,
records_path: Option<&str>,
record_ancestors: Option<&HashMap<String, String>>,
records_multi: &[RecordsMultiSpec],
op_field: &str,
) -> Result<Vec<Value>, FaucetError> {
if !records_multi.is_empty() {
return extract_multi(body, records_multi, op_field);
}
match record_ancestors {
Some(anc) if !anc.is_empty() => {
let rp = records_path.ok_or_else(|| {
FaucetError::Config("rest: `record_ancestors` requires `records_path`".into())
})?;
extract_with_ancestors(body, rp, anc)
}
_ => extract_records(body, records_path),
}
}
pub fn extract_multi(
body: &Value,
specs: &[RecordsMultiSpec],
op_field: &str,
) -> Result<Vec<Value>, FaucetError> {
let mut out = Vec::new();
for spec in specs {
let records = extract_records(body, Some(&spec.path))?;
for mut rec in records {
if let Some(map) = rec.as_object_mut() {
map.insert(op_field.to_owned(), Value::String(spec.op.clone()));
}
out.push(rec);
}
}
Ok(out)
}
fn split_ancestor_path(records_path: &str) -> Option<(String, String)> {
let idx = records_path.find("[*]")?;
let ancestor = format!("{}[*]", &records_path[..idx]);
let remainder = records_path[idx + "[*]".len()..].to_owned();
Some((ancestor, remainder))
}
fn query_relative(node: &Value, rel: &str) -> Result<Vec<Value>, FaucetError> {
let rel = rel.trim();
if rel.is_empty() {
return Ok(vec![node.clone()]);
}
let query = if rel.starts_with('$') {
rel.to_owned()
} else if rel.starts_with('.') || rel.starts_with('[') {
format!("${rel}")
} else {
format!("$.{rel}")
};
let results = node
.query(&query)
.map_err(|e| FaucetError::JsonPath(format!("{e}")))?;
Ok(results.into_iter().cloned().collect())
}
pub fn extract_with_ancestors(
body: &Value,
records_path: &str,
ancestors: &HashMap<String, String>,
) -> Result<Vec<Value>, FaucetError> {
let (ancestor_path, remainder) = split_ancestor_path(records_path).ok_or_else(|| {
FaucetError::Config(format!(
"rest: `record_ancestors` requires a `records_path` containing an array wildcard `[*]` \
(got '{records_path}')"
))
})?;
let ancestor_nodes = body
.query(&ancestor_path)
.map_err(|e| FaucetError::JsonPath(format!("{e}")))?;
let mut out = Vec::new();
for anc in ancestor_nodes {
let mut lifted: Vec<(String, Value)> = Vec::with_capacity(ancestors.len());
for (dest, rel) in ancestors {
if let Some(v) = query_relative(anc, rel)?.into_iter().next() {
lifted.push((dest.clone(), v));
}
}
for mut leaf in query_relative(anc, &remainder)? {
if let Some(map) = leaf.as_object_mut() {
for (dest, v) in &lifted {
map.insert(dest.clone(), v.clone());
}
}
out.push(leaf);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn stripe_events() -> Value {
json!({
"data": [
{
"id": "evt_1",
"created": 1000,
"type": "invoice.paid",
"data": { "object": { "id": "in_1", "amount": 500 } }
},
{
"id": "evt_2",
"created": 2000,
"type": "invoice.paid",
"data": { "object": { "id": "in_2", "amount": 700 } }
}
]
})
}
#[test]
fn record_ancestors_lifts_envelope_fields() {
let anc = HashMap::from([
("event_id".to_owned(), "id".to_owned()),
("event_created".to_owned(), "created".to_owned()),
]);
let out = extract_with_ancestors(&stripe_events(), "$.data[*].data.object", &anc).unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0]["id"], "in_1");
assert_eq!(out[0]["amount"], 500);
assert_eq!(out[0]["event_id"], "evt_1");
assert_eq!(out[0]["event_created"], 1000);
assert_eq!(out[1]["id"], "in_2");
assert_eq!(out[1]["event_id"], "evt_2");
assert_eq!(out[1]["event_created"], 2000);
}
#[test]
fn record_ancestors_skips_absent_ancestor_fields() {
let anc = HashMap::from([("missing".to_owned(), "nope".to_owned())]);
let out = extract_with_ancestors(&stripe_events(), "$.data[*].data.object", &anc).unwrap();
assert_eq!(out.len(), 2);
assert!(out[0].get("missing").is_none());
assert_eq!(out[0]["id"], "in_1");
}
#[test]
fn split_ancestor_path_splits_at_first_wildcard() {
assert_eq!(
split_ancestor_path("$.data[*].data.object"),
Some(("$.data[*]".to_owned(), ".data.object".to_owned()))
);
assert_eq!(
split_ancestor_path("$.items[*]"),
Some(("$.items[*]".to_owned(), String::new()))
);
assert_eq!(split_ancestor_path("$.data"), None);
}
#[test]
fn extract_multi_stamps_op_marker() {
let body = json!({
"added": [{"id": 1}, {"id": 2}],
"modified": [{"id": 3}],
"removed": [{"id": 4}]
});
let specs = vec![
RecordsMultiSpec {
path: "$.added[*]".into(),
op: "upsert".into(),
},
RecordsMultiSpec {
path: "$.modified[*]".into(),
op: "upsert".into(),
},
RecordsMultiSpec {
path: "$.removed[*]".into(),
op: "delete".into(),
},
];
let out = extract_multi(&body, &specs, "_op").unwrap();
assert_eq!(out.len(), 4);
assert_eq!(out[0]["id"], 1);
assert_eq!(out[0]["_op"], "upsert");
assert_eq!(out[2]["id"], 3);
assert_eq!(out[2]["_op"], "upsert");
assert_eq!(out[3]["id"], 4);
assert_eq!(out[3]["_op"], "delete");
}
#[test]
fn extract_multi_handles_missing_and_empty_arrays() {
let body = json!({ "added": [{"id": 1}], "removed": [] });
let specs = vec![
RecordsMultiSpec {
path: "$.added[*]".into(),
op: "u".into(),
},
RecordsMultiSpec {
path: "$.modified[*]".into(), op: "u".into(),
},
RecordsMultiSpec {
path: "$.removed[*]".into(), op: "d".into(),
},
];
let out = extract_multi(&body, &specs, "_op").unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0]["_op"], "u");
}
#[test]
fn extract_configured_routes_by_mode() {
let body = json!({ "added": [{"id": 1}], "removed": [{"id": 2}] });
let specs = vec![
RecordsMultiSpec {
path: "$.added[*]".into(),
op: "upsert".into(),
},
RecordsMultiSpec {
path: "$.removed[*]".into(),
op: "delete".into(),
},
];
let out = extract_configured(&body, None, None, &specs, "_op").unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[1]["_op"], "delete");
let anc = HashMap::from([("event_id".to_owned(), "id".to_owned())]);
let out = extract_configured(
&stripe_events(),
Some("$.data[*].data.object"),
Some(&anc),
&[],
"_op",
)
.unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0]["event_id"], "evt_1");
let body = json!({ "items": [{"id": 9}] });
let out = extract_configured(&body, Some("$.items[*]"), None, &[], "_op").unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0]["id"], 9);
}
}