metabase 0.1.1

Ergonomic Rust SDK for Metabase's HTTP API, with async and blocking clients.
Documentation
use std::borrow::Cow;

use http::HeaderMap;
use serde::Serialize;
use serde_json::Value;
use url::Url;

use crate::error::Error;

pub(crate) const DEFAULT_BODY_SNIPPET_LIMIT: usize = 4 * 1024;

pub(crate) fn normalize_base_url(mut base_url: Url) -> Url {
    if !base_url.path().ends_with('/') {
        let normalized = format!("{}/", base_url.path().trim_end_matches('/'));
        base_url.set_path(&normalized);
    }
    base_url
}

pub(crate) fn build_url(base_url: &Url, path: &[&str]) -> Result<Url, Error> {
    let mut url = base_url.clone();

    {
        let mut segments = url
            .path_segments_mut()
            .map_err(|()| Error::build_url(url::ParseError::RelativeUrlWithoutBase))?;
        segments.pop_if_empty();
        for segment in path {
            segments.push(segment);
        }
    }

    Ok(url)
}

pub(crate) fn set_query<Q: Serialize + ?Sized>(
    url: &mut Url,
    query: Option<&Q>,
) -> Result<(), Error> {
    match query {
        None => {
            url.set_query(None);
            Ok(())
        }
        Some(query) => {
            let value = serde_json::to_value(query).map_err(Error::serialize_query)?;
            let pairs = query_pairs(value).map_err(Error::serialize_query)?;

            if pairs.is_empty() {
                url.set_query(None);
                return Ok(());
            }

            let mut serializer = url::form_urlencoded::Serializer::new(String::new());
            for (key, value) in pairs {
                serializer.append_pair(&key, &value);
            }
            let query = serializer.finish();
            url.set_query(Some(&query));
            Ok(())
        }
    }
}

#[derive(Debug)]
struct QueryPairsError(&'static str);

impl std::fmt::Display for QueryPairsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl std::error::Error for QueryPairsError {}

fn query_pairs(value: Value) -> Result<Vec<(String, String)>, QueryPairsError> {
    match value {
        Value::Null => Ok(Vec::new()),
        Value::Object(map) => query_pairs_from_object(map),
        Value::Array(items) => query_pairs_from_pair_list(items),
        _ => Err(QueryPairsError(
            "query parameters must serialize to an object or a list of key/value pairs",
        )),
    }
}

fn query_pairs_from_object(
    map: serde_json::Map<String, Value>,
) -> Result<Vec<(String, String)>, QueryPairsError> {
    let mut pairs = Vec::new();
    for (key, value) in map {
        push_query_pairs(&mut pairs, key, value)?;
    }
    Ok(pairs)
}

fn query_pairs_from_pair_list(items: Vec<Value>) -> Result<Vec<(String, String)>, QueryPairsError> {
    let mut pairs = Vec::new();
    for item in items {
        let Value::Array(pair) = item else {
            return Err(QueryPairsError(
                "query pair list must be a list of [key, value] arrays",
            ));
        };
        if pair.len() != 2 {
            return Err(QueryPairsError(
                "query pair list entries must have length 2",
            ));
        }
        let key = scalar_to_string(&pair[0]).ok_or(QueryPairsError(
            "query pair keys must be strings, numbers, or booleans",
        ))?;
        let value = scalar_to_string(&pair[1]).ok_or(QueryPairsError(
            "query pair values must be strings, numbers, or booleans",
        ))?;
        pairs.push((key, value));
    }
    Ok(pairs)
}

fn push_query_pairs(
    pairs: &mut Vec<(String, String)>,
    key: String,
    value: Value,
) -> Result<(), QueryPairsError> {
    match value {
        Value::Null => Ok(()),
        Value::Bool(_) | Value::Number(_) | Value::String(_) => {
            let value = scalar_to_string(&value).ok_or(QueryPairsError(
                "query values must be strings, numbers, or booleans",
            ))?;
            pairs.push((key, value));
            Ok(())
        }
        Value::Array(items) => {
            for item in items {
                if item.is_null() {
                    continue;
                }
                if !matches!(item, Value::Bool(_) | Value::Number(_) | Value::String(_)) {
                    return Err(QueryPairsError(
                        "query arrays must contain only strings, numbers, or booleans",
                    ));
                }
                let value = scalar_to_string(&item).ok_or(QueryPairsError(
                    "query arrays must contain only strings, numbers, or booleans",
                ))?;
                pairs.push((key.clone(), value));
            }
            Ok(())
        }
        Value::Object(_) => Err(QueryPairsError("nested query objects are not supported")),
    }
}

fn scalar_to_string(value: &Value) -> Option<String> {
    match value {
        Value::Null => None,
        Value::Bool(v) => Some(if *v {
            "true".to_owned()
        } else {
            "false".to_owned()
        }),
        Value::Number(v) => Some(v.to_string()),
        Value::String(v) => Some(v.clone()),
        Value::Array(_) | Value::Object(_) => None,
    }
}

pub(crate) fn body_snippet(bytes: &[u8], limit: usize) -> String {
    let len = bytes.len().min(limit);
    match String::from_utf8_lossy(&bytes[..len]) {
        Cow::Borrowed(s) => s.to_string(),
        Cow::Owned(s) => s,
    }
}

pub(crate) fn capture_body_snippet(bytes: &[u8], limit: usize, redact: bool) -> String {
    let snippet = body_snippet(bytes, limit);
    if !redact {
        return snippet;
    }

    if !looks_sensitive(&snippet) {
        return snippet;
    }

    redact_body_snippet(&snippet)
}

pub(crate) fn extract_request_id(headers: &HeaderMap) -> Option<String> {
    const CANDIDATES: [&str; 3] = ["x-request-id", "x-amzn-requestid", "x-correlation-id"];

    for name in CANDIDATES {
        let Some(value) = headers.get(name) else {
            continue;
        };
        let Ok(value) = value.to_str() else {
            continue;
        };

        let trimmed = value.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_owned());
        }
    }

    None
}

fn looks_sensitive(snippet: &str) -> bool {
    let lower = snippet.to_ascii_lowercase();
    const MARKERS: [&str; 8] = [
        "token",
        "password",
        "secret",
        "api_key",
        "api-key",
        "authorization",
        "cookie",
        "session",
    ];

    MARKERS.iter().any(|m| lower.contains(m))
}

fn redact_body_snippet(snippet: &str) -> String {
    match serde_json::from_str::<Value>(snippet) {
        Ok(mut value) => {
            redact_json_value(&mut value);
            serde_json::to_string(&value).unwrap_or_else(|_| "<redacted>".to_owned())
        }
        Err(_) => "<redacted>".to_owned(),
    }
}

fn redact_json_value(value: &mut Value) {
    match value {
        Value::Object(map) => {
            for (key, value) in map.iter_mut() {
                if is_sensitive_key(key) {
                    *value = Value::String("<redacted>".to_owned());
                    continue;
                }
                redact_json_value(value);
            }
        }
        Value::Array(items) => {
            for item in items {
                redact_json_value(item);
            }
        }
        _ => {}
    }
}

fn is_sensitive_key(key: &str) -> bool {
    let key = key.to_ascii_lowercase();
    const KEYS: [&str; 6] = [
        "password", "token", "secret", "api_key", "api-key", "session",
    ];
    KEYS.iter().any(|k| key.contains(k))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::error::Error as StdError;

    #[test]
    fn normalize_base_url_adds_trailing_slash() -> Result<(), Box<dyn StdError + Send + Sync>> {
        let base_url = Url::parse("https://example.com/metabase")?;
        let normalized = normalize_base_url(base_url);
        assert_eq!(normalized.as_str(), "https://example.com/metabase/");
        Ok(())
    }

    #[test]
    fn build_url_encodes_path_segments() -> Result<(), Box<dyn StdError + Send + Sync>> {
        let base_url = Url::parse("https://example.com/api/")?;
        let url = build_url(&base_url, &["a/b", "c"])?;
        assert_eq!(url.as_str(), "https://example.com/api/a%2Fb/c");
        Ok(())
    }

    #[test]
    fn set_query_serializes_parameters() -> Result<(), Box<dyn StdError + Send + Sync>> {
        #[derive(serde::Serialize)]
        struct Params<'a> {
            q: &'a str,
            #[serde(skip_serializing_if = "Option::is_none")]
            limit: Option<u32>,
            tags: Vec<&'a str>,
        }

        let mut url = Url::parse("https://example.com/api/")?;
        let params = Params {
            q: "hello world",
            limit: None,
            tags: vec!["a", "b"],
        };
        set_query(&mut url, Some(&params))?;

        let pairs: Vec<(String, String)> = url
            .query_pairs()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect();
        assert!(pairs.contains(&("q".to_owned(), "hello world".to_owned())));
        assert!(pairs.contains(&("tags".to_owned(), "a".to_owned())));
        assert!(pairs.contains(&("tags".to_owned(), "b".to_owned())));
        assert!(!pairs.iter().any(|(k, _)| k == "limit"));
        Ok(())
    }

    #[test]
    fn capture_body_snippet_redacts_sensitive_json() {
        let bytes = br#"{"token":"abcd","nested":{"password":"pw"}}"#;
        let snippet = capture_body_snippet(bytes, 1024, true);
        assert!(snippet.contains("<redacted>"));
        assert!(!snippet.contains("abcd"));
        assert!(!snippet.contains("pw"));
    }

    #[test]
    fn capture_body_snippet_leaves_nonsensitive_content() {
        let bytes = br#"{"message":"boom"}"#;
        let snippet = capture_body_snippet(bytes, 1024, true);
        assert_eq!(snippet, r#"{"message":"boom"}"#);
    }

    #[test]
    fn capture_body_snippet_redacts_sensitive_text() {
        let bytes = b"token=abcd";
        let snippet = capture_body_snippet(bytes, 1024, true);
        assert_eq!(snippet, "<redacted>");
    }
}