Skip to main content

faucet_core/
util.rs

1//! Shared utilities used across faucet source and sink crates.
2
3use crate::FaucetError;
4use jsonpath_rust::JsonPath;
5use serde_json::Value;
6
7// ── SQL Utilities ───────────────────────────────────────────────────────────
8
9/// Quote a SQL identifier to prevent SQL injection.
10///
11/// Wraps the name in double quotes and doubles any embedded double-quotes
12/// per the SQL standard (ANSI SQL).
13///
14/// ```
15/// use faucet_core::util::quote_ident;
16/// assert_eq!(quote_ident("my_table"), "\"my_table\"");
17/// assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
18/// ```
19pub fn quote_ident(name: &str) -> String {
20    format!("\"{}\"", name.replace('"', "\"\""))
21}
22
23// ── JSONPath Extraction ─────────────────────────────────────────────────────
24
25/// Extract records from a JSON value using an optional JSONPath expression.
26///
27/// - If `path` is `Some`, queries the body with the JSONPath and returns
28///   all matched values.
29/// - If `path` is `None`, returns the body as-is: arrays are unpacked into
30///   individual records, objects/scalars are returned as a single-element vec.
31pub fn extract_records(body: &Value, path: Option<&str>) -> Result<Vec<Value>, FaucetError> {
32    match path {
33        Some(p) => {
34            let results = body
35                .query(p)
36                .map_err(|e| FaucetError::JsonPath(format!("invalid JSONPath '{p}': {e}")))?;
37            Ok(results.into_iter().cloned().collect())
38        }
39        None => match body {
40            Value::Array(arr) => Ok(arr.clone()),
41            other => Ok(vec![other.clone()]),
42        },
43    }
44}
45
46// ── HTTP Response Handling ──────────────────────────────────────────────────
47
48/// Check an HTTP response status and return a [`FaucetError::HttpStatus`] on
49/// non-success responses.
50///
51/// Reads the response body for error context, truncating to `max_body_len`
52/// bytes (default: 2048) to avoid large error messages.
53pub async fn check_http_response(
54    resp: reqwest::Response,
55    max_body_len: usize,
56) -> Result<reqwest::Response, FaucetError> {
57    if resp.status().is_success() {
58        return Ok(resp);
59    }
60
61    let status = resp.status().as_u16();
62    let url = resp.url().to_string();
63    let body_text = resp.text().await.unwrap_or_default();
64
65    let body = if body_text.len() > max_body_len {
66        let end = body_text.floor_char_boundary(max_body_len);
67        format!("{}...(truncated)", &body_text[..end])
68    } else {
69        body_text
70    };
71
72    Err(FaucetError::HttpStatus { status, url, body })
73}
74
75/// Default maximum body length for error responses.
76pub const DEFAULT_ERROR_BODY_MAX_LEN: usize = 2048;
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use serde_json::json;
82
83    // ── quote_ident ─────────────────────────────────────────────────────
84
85    #[test]
86    fn quote_ident_simple() {
87        assert_eq!(quote_ident("my_table"), "\"my_table\"");
88    }
89
90    #[test]
91    fn quote_ident_with_embedded_quotes() {
92        assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
93    }
94
95    #[test]
96    fn quote_ident_empty() {
97        assert_eq!(quote_ident(""), "\"\"");
98    }
99
100    #[test]
101    fn quote_ident_special_chars() {
102        assert_eq!(quote_ident("table; DROP"), "\"table; DROP\"");
103    }
104
105    // ── extract_records ─────────────────────────────────────────────────
106
107    #[test]
108    fn extract_with_path() {
109        let body = json!({"data": [{"id": 1}, {"id": 2}]});
110        let records = extract_records(&body, Some("$.data[*]")).unwrap();
111        assert_eq!(records.len(), 2);
112        assert_eq!(records[0]["id"], 1);
113    }
114
115    #[test]
116    fn extract_without_path_array() {
117        let body = json!([{"id": 1}, {"id": 2}]);
118        let records = extract_records(&body, None).unwrap();
119        assert_eq!(records.len(), 2);
120    }
121
122    #[test]
123    fn extract_without_path_object() {
124        let body = json!({"id": 1});
125        let records = extract_records(&body, None).unwrap();
126        assert_eq!(records.len(), 1);
127    }
128
129    #[test]
130    fn extract_empty_result() {
131        let body = json!({"data": []});
132        let records = extract_records(&body, Some("$.data[*]")).unwrap();
133        assert!(records.is_empty());
134    }
135
136    #[test]
137    fn extract_invalid_path_returns_error() {
138        let body = json!({"data": 1});
139        // jsonpath-rust handles most paths gracefully; test error propagation.
140        let result = extract_records(&body, Some("$.data[*]"));
141        // This should succeed (empty match) or fail; either is fine as long as
142        // it doesn't panic.
143        let _ = result;
144    }
145}