athena-gateway 3.18.0

Portable gateway request contracts and normalization primitives for Athena
Documentation
//! Portable `/gateway/update` request planning helpers.
//!
//! This module owns the body-based request-domain logic that can be shared
//! across route adapters: qualified table resolution, update payload
//! extraction, `room_id` validation/coercion, condition sorting, and derived
//! table-based write-right calculation. Runtime adapters keep auth fallback
//! ordering, deferred queueing, pool resolution, execution, logging, and HTTP
//! response construction.

use serde_json::{Number, Value};

use crate::{
    GatewayUpdateRequest, extract_update_payload, normalize_column_name,
    parse_conditions_from_body, parse_room_id_value, qualify_gateway_table_name,
    schema_name_from_body, write_right_for_resource,
};

/// Portable request plan derived from a canonical `/gateway/update` payload.
#[derive(Debug, Clone)]
pub struct GatewayUpdateRequestPlan {
    /// Canonical update request with validated conditions and normalized SET
    /// payload.
    pub request: GatewayUpdateRequest,
    /// Qualified `schema.table` target used for downstream SQL execution.
    pub qualified_table_name: String,
    /// Table-name-based gateway write right derived from the request target.
    pub required_write_right: String,
}

/// Validation errors for portable `/gateway/update` planning.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GatewayUpdateRequestPlanError {
    /// The request did not include `table_name`.
    MissingTableName,
    /// The table selector or schema override was invalid.
    InvalidSchemaTableSelector(String),
    /// The request did not include a valid update payload.
    MissingUpdatePayload,
    /// A condition value failed gateway compatibility validation.
    InvalidConditionValue(String),
    /// The request did not include any conditions.
    MissingConditions,
}

impl GatewayUpdateRequestPlanError {
    /// Returns the stable public summary used by route adapters.
    pub const fn summary(&self) -> &'static str {
        match self {
            Self::MissingTableName => "Missing required field",
            Self::InvalidSchemaTableSelector(_) => "Invalid schema/table selector",
            Self::MissingUpdatePayload => "Missing update payload",
            Self::InvalidConditionValue(_) => "Invalid condition value",
            Self::MissingConditions => "Missing conditions",
        }
    }

    /// Returns the stable public detail string used by route adapters.
    pub fn detail(&self) -> String {
        match self {
            Self::MissingTableName => "table_name is required".to_string(),
            Self::InvalidSchemaTableSelector(message)
            | Self::InvalidConditionValue(message) => message.clone(),
            Self::MissingUpdatePayload => "update payload required: provide 'columns' (array of objects with column names and values), or 'data' / 'set' object".to_string(),
            Self::MissingConditions => {
                "at least one condition is required for update (e.g. eq_column / eq_value)"
                    .to_string()
            }
        }
    }
}

fn write_resource_name_for_table(table_name: &str) -> &str {
    table_name.rsplit('.').next().unwrap_or(table_name).trim()
}

/// Builds the portable `/gateway/update` execution plan from a parsed JSON body.
pub fn build_gateway_update_request_plan(
    body: &Value,
    force_camel_case_to_snake_case: bool,
) -> Result<GatewayUpdateRequestPlan, GatewayUpdateRequestPlanError> {
    let table_name = body
        .get("table_name")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or(GatewayUpdateRequestPlanError::MissingTableName)?
        .to_string();

    let schema_name = schema_name_from_body(body);
    let qualified_table_name = qualify_gateway_table_name(&table_name, schema_name.as_deref())
        .map_err(GatewayUpdateRequestPlanError::InvalidSchemaTableSelector)?;

    let set_payload = Value::Object(
        extract_update_payload(body, force_camel_case_to_snake_case)
            .ok_or(GatewayUpdateRequestPlanError::MissingUpdatePayload)?,
    );

    if let Some(additional_conditions) = body.get("conditions").and_then(Value::as_array) {
        for condition in additional_conditions {
            let Some(eq_column) = condition.get("eq_column").and_then(Value::as_str) else {
                continue;
            };
            let normalized_for_validation =
                normalize_column_name(eq_column, force_camel_case_to_snake_case);
            if (normalized_for_validation == "room_id" || eq_column == "roomId")
                && condition.get("eq_value").is_none()
            {
                return Err(GatewayUpdateRequestPlanError::InvalidConditionValue(
                    "room_id is required and must be numeric".to_string(),
                ));
            }
        }
    }

    let mut conditions = parse_conditions_from_body(body)
        .into_iter()
        .map(|condition| {
            let normalized_for_validation =
                normalize_column_name(&condition.eq_column, force_camel_case_to_snake_case);
            let eq_value = if normalized_for_validation == "room_id"
                || condition.eq_column == "roomId"
            {
                let room_id = parse_room_id_value(&condition.eq_value).map_err(|err| {
                    GatewayUpdateRequestPlanError::InvalidConditionValue(err.message().to_string())
                })?;
                Value::Number(Number::from(room_id))
            } else {
                condition.eq_value
            };

            Ok(crate::GatewayRequestCondition::new(
                condition.eq_column,
                eq_value,
            ))
        })
        .collect::<Result<Vec<_>, GatewayUpdateRequestPlanError>>()?;

    if conditions.is_empty() {
        return Err(GatewayUpdateRequestPlanError::MissingConditions);
    }
    conditions.sort_by(|a, b| a.eq_column.cmp(&b.eq_column));

    let request = GatewayUpdateRequest {
        table_name: table_name.clone(),
        schema_name,
        conditions,
        data: set_payload,
    };

    Ok(GatewayUpdateRequestPlan {
        request,
        qualified_table_name,
        required_write_right: write_right_for_resource(Some(write_resource_name_for_table(
            &table_name,
        ))),
    })
}

#[cfg(test)]
mod tests {
    use super::{GatewayUpdateRequestPlanError, build_gateway_update_request_plan};
    use serde_json::{Number, Value, json};

    #[test]
    fn update_plan_requires_table_name() {
        let err = build_gateway_update_request_plan(
            &json!({
                "data": { "status": "active" },
                "conditions": [{ "eq_column": "id", "eq_value": "1" }]
            }),
            false,
        )
        .expect_err("missing table_name should fail");

        assert_eq!(err, GatewayUpdateRequestPlanError::MissingTableName);
        assert_eq!(err.summary(), "Missing required field");
        assert_eq!(err.detail(), "table_name is required");
    }

    #[test]
    fn update_plan_rejects_invalid_schema_table_selectors() {
        let err = build_gateway_update_request_plan(
            &json!({
                "table_name": "public.users",
                "schema_name": "analytics",
                "data": { "status": "active" },
                "conditions": [{ "eq_column": "id", "eq_value": "1" }]
            }),
            false,
        )
        .expect_err("ambiguous schema/table should fail");

        match err {
            GatewayUpdateRequestPlanError::InvalidSchemaTableSelector(message) => {
                assert!(message.contains("must not include a schema prefix"));
            }
            other => panic!("expected invalid schema/table selector, got {other:?}"),
        }
    }

    #[test]
    fn update_plan_requires_update_payload() {
        let err = build_gateway_update_request_plan(
            &json!({
                "table_name": "users",
                "conditions": [{ "eq_column": "id", "eq_value": "1" }]
            }),
            false,
        )
        .expect_err("missing update payload should fail");

        assert_eq!(err, GatewayUpdateRequestPlanError::MissingUpdatePayload);
        assert_eq!(err.summary(), "Missing update payload");
    }

    #[test]
    fn update_plan_rejects_room_id_without_value() {
        let err = build_gateway_update_request_plan(
            &json!({
                "table_name": "rooms",
                "data": { "status": "active" },
                "conditions": [{ "eq_column": "roomId" }]
            }),
            true,
        )
        .expect_err("missing room_id value should fail");

        assert_eq!(
            err,
            GatewayUpdateRequestPlanError::InvalidConditionValue(
                "room_id is required and must be numeric".to_string()
            )
        );
    }

    #[test]
    fn update_plan_requires_conditions() {
        let err = build_gateway_update_request_plan(
            &json!({
                "table_name": "users",
                "data": { "status": "active" }
            }),
            false,
        )
        .expect_err("missing conditions should fail");

        assert_eq!(err, GatewayUpdateRequestPlanError::MissingConditions);
        assert_eq!(
            err.detail(),
            "at least one condition is required for update (e.g. eq_column / eq_value)"
        );
    }

    #[test]
    fn update_plan_coerces_room_id_sorts_conditions_and_derives_table_write_right() {
        let plan = build_gateway_update_request_plan(
            &json!({
                "table_name": "rooms",
                "schema_name": "public",
                "set": { "status": "active" },
                "conditions": [
                    { "eq_column": "roomId", "eq_value": "42" },
                    { "eq_column": "name", "eq_value": "lobby" }
                ]
            }),
            true,
        )
        .expect("plan should parse");

        assert_eq!(plan.qualified_table_name, "public.rooms");
        assert_eq!(plan.required_write_right, "rooms.write");
        assert_eq!(plan.request.data, json!({ "status": "active" }));
        assert_eq!(
            plan.request.conditions,
            vec![
                crate::GatewayRequestCondition::new(
                    "name".to_string(),
                    Value::String("lobby".to_string())
                ),
                crate::GatewayRequestCondition::new(
                    "roomId".to_string(),
                    Value::Number(Number::from(42))
                ),
            ]
        );
    }
}