athena_rs 3.3.0

Database gateway API
Documentation
//! Request parsing for gateway fetch POST: sort options and cache key material.

use actix_web::HttpResponse;
use serde_json::{Number, Value, json};

use super::conditions::RequestCondition;
use super::room_id;
use super::types::SortOptions;
use crate::utils::format::normalize_column_name;

/// Parses optional `sortBy` or `sort_by` from the request body.
pub fn parse_sort_options_from_body(
    body: Option<&Value>,
    force_snake: bool,
) -> Option<SortOptions> {
    let obj = body?
        .get("sort_by")
        .or_else(|| body?.get("sortBy"))
        .and_then(Value::as_object)?;
    let field = obj
        .get("field")
        .or_else(|| obj.get("column"))
        .and_then(Value::as_str)?;
    let normalized = normalize_column_name(field, force_snake);
    if normalized.is_empty() {
        return None;
    }
    let ascending = obj
        .get("direction")
        .and_then(Value::as_str)
        .map(|s| matches!(s.to_lowercase().as_str(), "asc" | "ascending"))
        .unwrap_or(true);
    Some(SortOptions {
        column: normalized,
        ascending,
    })
}

/// Builds the hashed cache key used for POST gateway fetch.
pub fn build_fetch_hashed_cache_key(
    table_name: &str,
    conditions: &[RequestCondition],
    columns_vec: &[String],
    limit: i64,
    strip_nulls: bool,
    client_name: &str,
    sort_options: Option<&SortOptions>,
) -> String {
    build_fetch_hashed_cache_key_with_hash_len(
        table_name,
        conditions,
        columns_vec,
        limit,
        strip_nulls,
        client_name,
        sort_options,
        16,
    )
}

pub(super) fn build_fetch_hashed_cache_key_legacy8(
    table_name: &str,
    conditions: &[RequestCondition],
    columns_vec: &[String],
    limit: i64,
    strip_nulls: bool,
    client_name: &str,
    sort_options: Option<&SortOptions>,
) -> String {
    build_fetch_hashed_cache_key_with_hash_len(
        table_name,
        conditions,
        columns_vec,
        limit,
        strip_nulls,
        client_name,
        sort_options,
        8,
    )
}

fn build_fetch_hashed_cache_key_with_hash_len(
    table_name: &str,
    conditions: &[RequestCondition],
    columns_vec: &[String],
    limit: i64,
    strip_nulls: bool,
    client_name: &str,
    sort_options: Option<&SortOptions>,
    hash_len: usize,
) -> String {
    let mut normalized_conditions: Vec<(String, Value, String)> = conditions
        .iter()
        .map(|c| {
            let serialized_value = serde_json::to_string(&c.eq_value).unwrap_or_default();
            (c.eq_column.clone(), c.eq_value.clone(), serialized_value)
        })
        .collect();
    normalized_conditions.sort_by(|a, b| a.0.cmp(&b.0).then(a.2.cmp(&b.2)));

    let first_eq_column: &str = normalized_conditions
        .first()
        .map_or("_", |(column, _, _)| column.as_str());
    let hash_input: Value = json!({
        "columns": columns_vec,
        "conditions": normalized_conditions.iter().map(|(eq_column, eq_value, _)| json!({
            "eq_column": eq_column,
            "eq_value": eq_value.clone()
        })).collect::<Vec<_>>(),
        "limit": limit,
        "strip_nulls": strip_nulls,
        "client": client_name,
        "sort": sort_options.map(|s| json!({"column": s.column, "ascending": s.ascending})),
    });
    let hash_str: String = sha256::digest(serde_json::to_string(&hash_input).unwrap_or_default());
    let short_hash: &str = &hash_str[..hash_len.min(hash_str.len())];
    format!(
        "{}:{}:{}:{}:{}:{}",
        table_name,
        first_eq_column,
        columns_vec.join(","),
        limit,
        strip_nulls,
        short_hash
    )
}

/// Parses `conditions` from a gateway fetch JSON body; returns HTTP 400 on invalid `room_id` filters.
pub fn parse_gateway_fetch_conditions(
    json_body: &Value,
    force_camel_case_to_snake_case: bool,
) -> Result<Vec<RequestCondition>, HttpResponse> {
    let mut conditions = Vec::new();
    let Some(additional_conditions) = json_body.get("conditions").and_then(|c| c.as_array()) else {
        return Ok(conditions);
    };
    for condition in additional_conditions {
        if let Some(eq_column) = condition.get("eq_column").and_then(Value::as_str) {
            let eq_column_str = eq_column.to_string();
            let normalized_for_validation =
                normalize_column_name(eq_column, force_camel_case_to_snake_case);

            let eq_value_raw = match condition.get("eq_value") {
                Some(value) => value.clone(),
                None => {
                    if normalized_for_validation == "room_id" || eq_column_str == "roomId" {
                        return Err(HttpResponse::BadRequest().json(json!({
                            "error": "room_id is required and must be numeric"
                        })));
                    }
                    continue;
                }
            };
            let eq_value: Value =
                if normalized_for_validation == "room_id" || eq_column_str == "roomId" {
                    match room_id::parse_room_id_value(&eq_value_raw) {
                        Ok(room_id) => Value::Number(Number::from(room_id)),
                        Err(err_msg) => {
                            return Err(HttpResponse::BadRequest().json(json!({
                                "error": err_msg
                            })));
                        }
                    }
                } else {
                    eq_value_raw
                };
            conditions.push(RequestCondition::new(eq_column_str, eq_value));
        }
    }
    Ok(conditions)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn hashed_cache_key_stable_for_same_inputs() {
        let conditions = vec![RequestCondition::new("workspace_id".into(), json!("abc"))];
        let k1 = build_fetch_hashed_cache_key(
            "users",
            &conditions,
            &["id".into(), "email".into()],
            10,
            false,
            "supabase",
            None,
        );
        let k2 = build_fetch_hashed_cache_key(
            "users",
            &conditions,
            &["id".into(), "email".into()],
            10,
            false,
            "supabase",
            None,
        );
        assert_eq!(k1, k2);
    }

    #[test]
    fn hashed_cache_key_stable_for_reordered_conditions() {
        let conditions_a = vec![
            RequestCondition::new("workspace_id".into(), json!("abc")),
            RequestCondition::new("room_id".into(), json!(123)),
        ];
        let conditions_b = vec![
            RequestCondition::new("room_id".into(), json!(123)),
            RequestCondition::new("workspace_id".into(), json!("abc")),
        ];

        let k1 = build_fetch_hashed_cache_key(
            "users",
            &conditions_a,
            &["id".into(), "email".into()],
            10,
            false,
            "supabase",
            None,
        );
        let k2 = build_fetch_hashed_cache_key(
            "users",
            &conditions_b,
            &["id".into(), "email".into()],
            10,
            false,
            "supabase",
            None,
        );

        assert_eq!(k1, k2);
    }
}