athena-gateway 3.18.0

Portable gateway request contracts and normalization primitives for Athena
Documentation
//! Delete-request normalization helpers shared by gateway adapters.
//!
//! These helpers stay portable by only handling request-domain concerns:
//! normalizing the optional explicit `resource_id` column selector and
//! deriving the table name that runtime adapters should use when they fall
//! back to metadata-driven resource ID lookup.

use athena_driver::postgresql::column_resolver::resolve_information_schema_targets;

use crate::{
    GatewayDeleteRequest, delete_right_for_resource, normalize_column_name, sanitize_identifier,
};

/// Portable delete-side resource ID lookup plan.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatewayDeleteResourceIdPlan {
    /// Table name the runtime adapter should use for fallback ID-column lookup.
    pub lookup_table_name: String,
    /// Explicit normalized `resource_id` column, when the caller supplied one.
    pub explicit_column_name: Option<String>,
}

/// Portable delete request plan derived from the canonical `/gateway/delete`
/// payload.
#[derive(Debug, Clone)]
pub struct GatewayDeleteRequestPlan {
    /// Canonical delete request with trimmed identifiers and normalized
    /// explicit column name when present.
    pub request: GatewayDeleteRequest,
    /// Qualified `schema.table` target used for downstream execution.
    pub qualified_table_name: String,
    /// Delete resource-ID lookup plan used by runtime fallback loaders.
    pub resource_id_plan: GatewayDeleteResourceIdPlan,
    /// Table-name-based delete right derived from the request target.
    pub required_delete_right: String,
}

/// Validation errors for portable `/gateway/delete` planning.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GatewayDeleteRequestPlanError {
    /// The request did not include `table_name`.
    MissingTableName,
    /// The request did not include `resource_id`.
    MissingResourceId,
    /// The table selector or schema override was invalid.
    InvalidSchemaTableSelector(String),
    /// The request payload failed delete-side validation.
    InvalidDeletePayload(String),
}

impl GatewayDeleteRequestPlanError {
    /// Returns the stable public summary used by route adapters.
    pub const fn summary(&self) -> &'static str {
        match self {
            Self::MissingTableName | Self::MissingResourceId | Self::InvalidDeletePayload(_) => {
                "Invalid delete payload"
            }
            Self::InvalidSchemaTableSelector(_) => "Invalid schema/table selector",
        }
    }

    /// 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::MissingResourceId => "resource_id is required".to_string(),
            Self::InvalidSchemaTableSelector(message) | Self::InvalidDeletePayload(message) => {
                message.clone()
            }
        }
    }
}

fn delete_right_resource_name(table_name: &str) -> Option<&str> {
    let trimmed = table_name.trim();
    if trimmed.is_empty() {
        return None;
    }
    Some(trimmed.rsplit('.').next().unwrap_or(trimmed).trim())
}

/// Derives the table-name-based delete right hint for `/gateway/delete`.
pub fn delete_request_required_right(request: &GatewayDeleteRequest) -> String {
    delete_right_for_resource(delete_right_resource_name(&request.table_name))
}

/// Normalizes the table name used by metadata-backed delete resource-ID lookup.
///
/// When `allow_schema_names_prefixed_as_table_name` is enabled, `public.<table>`
/// collapses to `<table>` so legacy lookup paths keep using the unqualified
/// resource name. Non-`public` schema-qualified names are preserved.
pub fn normalize_delete_resource_id_lookup_table(
    table_name: &str,
    allow_schema_names_prefixed_as_table_name: bool,
) -> String {
    let trimmed = table_name.trim();
    if trimmed.is_empty() {
        return String::new();
    }
    if !allow_schema_names_prefixed_as_table_name {
        return trimmed.to_string();
    }
    match resolve_information_schema_targets(trimmed, true) {
        Ok((schema, table)) if schema.eq_ignore_ascii_case("public") => table,
        _ => trimmed.to_string(),
    }
}

/// Normalizes and validates an explicit delete `resource_id` column selector.
///
/// Callers should pass the raw request value. Empty strings and unsafe SQL
/// identifiers are rejected with a validation error message suitable for a
/// structured gateway error response.
pub fn normalize_delete_resource_id_column_name(
    requested_column_name: &str,
) -> Result<String, String> {
    let trimmed = requested_column_name.trim();
    if trimmed.is_empty() {
        return Err("column_name must not be empty when provided".to_string());
    }
    let normalized = normalize_column_name(trimmed, true);
    if sanitize_identifier(&normalized).is_none() {
        return Err(format!("invalid resource_id column_name `{trimmed}`"));
    }
    Ok(normalized)
}

/// Builds the portable delete resource-ID resolution plan for a request.
///
/// Runtime adapters can use [`GatewayDeleteResourceIdPlan::explicit_column_name`]
/// directly when present. Otherwise they should perform their own backend-
/// specific fallback lookup using [`GatewayDeleteResourceIdPlan::lookup_table_name`].
pub fn plan_delete_resource_id_resolution(
    table_name: &str,
    requested_column_name: Option<&str>,
    allow_schema_names_prefixed_as_table_name: bool,
) -> Result<GatewayDeleteResourceIdPlan, String> {
    Ok(GatewayDeleteResourceIdPlan {
        lookup_table_name: normalize_delete_resource_id_lookup_table(
            table_name,
            allow_schema_names_prefixed_as_table_name,
        ),
        explicit_column_name: requested_column_name
            .map(normalize_delete_resource_id_column_name)
            .transpose()?,
    })
}

/// Builds the portable `/gateway/delete` request plan from the canonical
/// request payload.
pub fn build_gateway_delete_request_plan(
    request: GatewayDeleteRequest,
    allow_schema_names_prefixed_as_table_name: bool,
) -> Result<GatewayDeleteRequestPlan, GatewayDeleteRequestPlanError> {
    let table_name = request.table_name.trim().to_string();
    if table_name.is_empty() {
        return Err(GatewayDeleteRequestPlanError::MissingTableName);
    }

    let resource_id = request.resource_id.trim().to_string();
    if resource_id.is_empty() {
        return Err(GatewayDeleteRequestPlanError::MissingResourceId);
    }

    let normalized_request = GatewayDeleteRequest {
        table_name: table_name.clone(),
        schema_name: request.schema_name,
        resource_id,
        column_name: request.column_name,
    };

    let qualified_table_name = normalized_request
        .qualified_table_name()
        .map_err(GatewayDeleteRequestPlanError::InvalidSchemaTableSelector)?;
    let resource_id_plan = plan_delete_resource_id_resolution(
        &table_name,
        normalized_request.column_name.as_deref(),
        allow_schema_names_prefixed_as_table_name,
    )
    .map_err(GatewayDeleteRequestPlanError::InvalidDeletePayload)?;

    Ok(GatewayDeleteRequestPlan {
        request: GatewayDeleteRequest {
            column_name: resource_id_plan.explicit_column_name.clone(),
            ..normalized_request
        },
        qualified_table_name,
        resource_id_plan,
        required_delete_right: delete_right_for_resource(delete_right_resource_name(&table_name)),
    })
}

#[cfg(test)]
mod tests {
    use super::{
        GatewayDeleteRequestPlanError, build_gateway_delete_request_plan,
        delete_request_required_right, normalize_delete_resource_id_column_name,
        normalize_delete_resource_id_lookup_table, plan_delete_resource_id_resolution,
    };
    use crate::GatewayDeleteRequest;

    #[test]
    fn strips_public_prefix_when_enabled() {
        let resolved =
            normalize_delete_resource_id_lookup_table("public.forms_blocks_summary_sections", true);
        assert_eq!(resolved, "forms_blocks_summary_sections");
    }

    #[test]
    fn keeps_qualified_name_when_disabled() {
        let resolved = normalize_delete_resource_id_lookup_table(
            "public.forms_blocks_summary_sections",
            false,
        );
        assert_eq!(resolved, "public.forms_blocks_summary_sections");
    }

    #[test]
    fn explicit_delete_resource_id_column_is_used_as_alias() {
        let resolved =
            normalize_delete_resource_id_column_name("id").expect("explicit column should resolve");

        assert_eq!(resolved, "id");
    }

    #[test]
    fn explicit_delete_resource_id_column_is_normalized() {
        let resolved = normalize_delete_resource_id_column_name("resourceId")
            .expect("explicit column should resolve");

        assert_eq!(resolved, "resource_id");
    }

    #[test]
    fn explicit_delete_resource_id_column_is_validated() {
        let err = normalize_delete_resource_id_column_name("id; DROP TABLE users")
            .expect_err("unsafe column should fail");

        assert!(err.contains("invalid resource_id column_name"));
    }

    #[test]
    fn delete_resource_id_plan_preserves_lookup_and_explicit_column() {
        let plan = plan_delete_resource_id_resolution("public.users", Some("userId"), true)
            .expect("plan should resolve");

        assert_eq!(plan.lookup_table_name, "users");
        assert_eq!(plan.explicit_column_name.as_deref(), Some("user_id"));
    }

    #[test]
    fn delete_request_right_uses_unqualified_table_name() {
        let request = GatewayDeleteRequest {
            table_name: "public.users".to_string(),
            schema_name: None,
            resource_id: "user-123".to_string(),
            column_name: None,
        };

        assert_eq!(delete_request_required_right(&request), "users.delete");
    }

    #[test]
    fn delete_request_plan_requires_table_name() {
        let err = build_gateway_delete_request_plan(
            GatewayDeleteRequest {
                table_name: " ".to_string(),
                schema_name: None,
                resource_id: "user-123".to_string(),
                column_name: None,
            },
            true,
        )
        .expect_err("missing table_name should fail");

        assert_eq!(err, GatewayDeleteRequestPlanError::MissingTableName);
        assert_eq!(err.summary(), "Invalid delete payload");
        assert_eq!(err.detail(), "table_name is required");
    }

    #[test]
    fn delete_request_plan_requires_resource_id() {
        let err = build_gateway_delete_request_plan(
            GatewayDeleteRequest {
                table_name: "users".to_string(),
                schema_name: None,
                resource_id: " ".to_string(),
                column_name: None,
            },
            true,
        )
        .expect_err("missing resource_id should fail");

        assert_eq!(err, GatewayDeleteRequestPlanError::MissingResourceId);
    }

    #[test]
    fn delete_request_plan_rejects_invalid_schema_selector() {
        let err = build_gateway_delete_request_plan(
            GatewayDeleteRequest {
                table_name: "public.users".to_string(),
                schema_name: Some("analytics".to_string()),
                resource_id: "user-123".to_string(),
                column_name: None,
            },
            true,
        )
        .expect_err("ambiguous schema/table should fail");

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

    #[test]
    fn delete_request_plan_rejects_invalid_explicit_column_name() {
        let err = build_gateway_delete_request_plan(
            GatewayDeleteRequest {
                table_name: "users".to_string(),
                schema_name: None,
                resource_id: "user-123".to_string(),
                column_name: Some("id; DROP TABLE users".to_string()),
            },
            true,
        )
        .expect_err("invalid explicit column should fail");

        match err {
            GatewayDeleteRequestPlanError::InvalidDeletePayload(message) => {
                assert!(message.contains("invalid resource_id column_name"));
            }
            other => panic!("expected invalid delete payload, got {other:?}"),
        }
    }

    #[test]
    fn delete_request_plan_normalizes_explicit_column_and_lookup_table() {
        let plan = build_gateway_delete_request_plan(
            GatewayDeleteRequest {
                table_name: "users".to_string(),
                schema_name: Some("public".to_string()),
                resource_id: " user-123 ".to_string(),
                column_name: Some("resourceId".to_string()),
            },
            true,
        )
        .expect("plan should resolve");

        assert_eq!(plan.qualified_table_name, "public.users");
        assert_eq!(plan.required_delete_right, "users.delete");
        assert_eq!(plan.resource_id_plan.lookup_table_name, "users");
        assert_eq!(
            plan.resource_id_plan.explicit_column_name.as_deref(),
            Some("resource_id")
        );
        assert_eq!(plan.request.resource_id, "user-123");
        assert_eq!(plan.request.column_name.as_deref(), Some("resource_id"));
    }
}