fakecloud-support 0.41.0

AWS Support (support) implementation for FakeCloud
//! Model-driven input validation for AWS Support.
//!
//! AWS validates request members against the Smithy model's `required`,
//! `length`, `range` and `enum` constraints and returns a validation error
//! before any business logic runs. This module reproduces that: the Support
//! Smithy model is embedded and parsed once, and [`validate_input`] checks a
//! request body's top-level members against the operation's input shape,
//! returning the message for the first violation.
//!
//! `@pattern` is intentionally not enforced (see `apply_traits` /
//! `validate_input`): the only patterned member (`serviceCode`) is taken by
//! operations that declare no generic validation exception, so a pattern
//! rejection would fall outside their Smithy error contract.

use std::collections::HashMap;
use std::sync::OnceLock;

use serde_json::Value;

/// The vendored Support Smithy model (byte-for-byte from aws/api-models-aws),
/// embedded so validation needs no runtime file access.
const MODEL_JSON: &str = include_str!("../../../aws-models/support.json");

/// The embedded model parses to exactly this many operations; a mismatch means
/// the vendored model drifted from the implemented surface.
#[cfg(test)]
pub const OPERATION_COUNT: usize = 16;

#[derive(Debug, Default)]
struct MemberConstraint {
    name: String,
    required: bool,
    min_len: Option<u64>,
    max_len: Option<u64>,
    min_val: Option<f64>,
    max_val: Option<f64>,
    enum_values: Option<Vec<String>>,
}

type OpConstraints = HashMap<String, Vec<MemberConstraint>>;

fn constraints() -> &'static OpConstraints {
    static CELL: OnceLock<OpConstraints> = OnceLock::new();
    CELL.get_or_init(build_constraints)
}

fn short(id: &str) -> &str {
    id.rsplit('#').next().unwrap_or(id)
}

fn build_constraints() -> OpConstraints {
    let model: Value = serde_json::from_str(MODEL_JSON).expect("embedded support model parses");
    let shapes = model
        .get("shapes")
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();
    let mut out = OpConstraints::new();
    for (sid, shape) in &shapes {
        if shape.get("type").and_then(Value::as_str) != Some("operation") {
            continue;
        }
        let op_name = short(sid).to_string();
        let Some(input_id) = shape.pointer("/input/target").and_then(Value::as_str) else {
            out.insert(op_name, Vec::new());
            continue;
        };
        let Some(input_shape) = shapes.get(input_id) else {
            out.insert(op_name, Vec::new());
            continue;
        };
        let mut members = Vec::new();
        if let Some(map) = input_shape.get("members").and_then(Value::as_object) {
            for (mname, member) in map {
                let mut c = MemberConstraint {
                    name: mname.clone(),
                    ..Default::default()
                };
                let member_traits = member.get("traits").and_then(Value::as_object);
                if let Some(t) = member_traits {
                    if t.contains_key("smithy.api#required") {
                        c.required = true;
                    }
                }
                // Constraints usually live on the target shape.
                if let Some(target_id) = member.get("target").and_then(Value::as_str) {
                    apply_shape_constraints(&shapes, target_id, &mut c);
                }
                // Length/range can also be reasserted on the member.
                if let Some(t) = member_traits {
                    apply_traits(t, &mut c);
                }
                members.push(c);
            }
        }
        out.insert(op_name, members);
    }
    out
}

fn apply_shape_constraints(
    shapes: &serde_json::Map<String, Value>,
    target_id: &str,
    c: &mut MemberConstraint,
) {
    let Some(shape) = shapes.get(target_id) else {
        return;
    };
    if let Some(t) = shape.get("traits").and_then(Value::as_object) {
        apply_traits(t, c);
    }
    // Smithy 2.0 enum shape: collect its enum values.
    if shape.get("type").and_then(Value::as_str) == Some("enum") {
        if let Some(map) = shape.get("members").and_then(Value::as_object) {
            let vals: Vec<String> = map
                .iter()
                .map(|(k, m)| {
                    m.pointer("/traits/smithy.api#enumValue")
                        .and_then(Value::as_str)
                        .map(String::from)
                        .unwrap_or_else(|| k.clone())
                })
                .collect();
            if !vals.is_empty() {
                c.enum_values = Some(vals);
            }
        }
    }
}

fn apply_traits(t: &serde_json::Map<String, Value>, c: &mut MemberConstraint) {
    if let Some(len) = t.get("smithy.api#length") {
        if let Some(min) = len.get("min").and_then(Value::as_u64) {
            c.min_len = Some(min);
        }
        if let Some(max) = len.get("max").and_then(Value::as_u64) {
            c.max_len = Some(max);
        }
    }
    if let Some(range) = t.get("smithy.api#range") {
        if let Some(min) = range.get("min").and_then(Value::as_f64) {
            c.min_val = Some(min);
        }
        if let Some(max) = range.get("max").and_then(Value::as_f64) {
            c.max_val = Some(max);
        }
    }
    if let Some(items) = t.get("smithy.api#enum").and_then(Value::as_array) {
        let vals: Vec<String> = items
            .iter()
            .filter_map(|i| i.get("value").and_then(Value::as_str).map(String::from))
            .collect();
        if !vals.is_empty() {
            c.enum_values = Some(vals);
        }
    }
    // `@pattern` is intentionally not collected: Support's `serviceCode`
    // pattern is enforced by operations that declare no validation exception,
    // so surfacing a pattern rejection would fall outside their Smithy error
    // contract. See `validate_input`.
}

/// Validate a request body against the operation's input-shape constraints.
/// Returns `Err(message)` for the first violation, or `Ok(())` when every
/// top-level member is in bounds. The message is surfaced as
/// `InvalidRequestException`, Support's canonical request-validation shape.
pub fn validate_input(op: &str, body: &Value) -> Result<(), String> {
    let Some(members) = constraints().get(op) else {
        return Ok(());
    };
    for c in members {
        let present = body.get(&c.name).filter(|v| !v.is_null());
        match present {
            None => {
                if c.required {
                    return Err(format!(
                        "1 validation error detected: Value null at '{}' failed to satisfy constraint: Member must not be null",
                        c.name
                    ));
                }
            }
            Some(v) => {
                if let Some(s) = v.as_str() {
                    let len = s.chars().count() as u64;
                    if let Some(min) = c.min_len {
                        if len < min {
                            return Err(len_msg(&c.name, s, "greater than or equal to", min));
                        }
                    }
                    if let Some(max) = c.max_len {
                        if len > max {
                            return Err(len_msg(&c.name, s, "less than or equal to", max));
                        }
                    }
                    if let Some(vals) = &c.enum_values {
                        if !vals.iter().any(|x| x == s) {
                            return Err(format!(
                                "1 validation error detected: Value '{}' at '{}' failed to satisfy constraint: Member must satisfy enum value set: {:?}",
                                s, c.name, vals
                            ));
                        }
                    }
                    // `@pattern` is deliberately NOT enforced. The Support model
                    // constrains `serviceCode` with `^[0-9a-z\-_]+$`, but the
                    // operations that take it (`CreateCase`,
                    // `DescribeCreateCaseOptions`) declare no generic
                    // validation exception, so surfacing a pattern rejection
                    // would return an error code outside the operation's Smithy
                    // contract. Required/length validation (which every op that
                    // rejects bad input can express) is kept; pattern shape is
                    // accepted verbatim.
                }
                if let Some(arr) = v.as_array() {
                    let len = arr.len() as u64;
                    if let Some(min) = c.min_len {
                        if len < min {
                            return Err(len_list_msg(&c.name, "greater than or equal to", min));
                        }
                    }
                    if let Some(max) = c.max_len {
                        if len > max {
                            return Err(len_list_msg(&c.name, "less than or equal to", max));
                        }
                    }
                }
                if let Some(n) = v.as_f64() {
                    if let Some(min) = c.min_val {
                        if n < min {
                            return Err(format!(
                                "1 validation error detected: Value '{}' at '{}' failed to satisfy constraint: Member must have value greater than or equal to {}",
                                n, c.name, min
                            ));
                        }
                    }
                    if let Some(max) = c.max_val {
                        if n > max {
                            return Err(format!(
                                "1 validation error detected: Value '{}' at '{}' failed to satisfy constraint: Member must have value less than or equal to {}",
                                n, c.name, max
                            ));
                        }
                    }
                }
            }
        }
    }
    Ok(())
}

fn len_msg(name: &str, val: &str, cmp: &str, bound: u64) -> String {
    format!(
        "1 validation error detected: Value '{val}' at '{name}' failed to satisfy constraint: Member must have length {cmp} {bound}"
    )
}

fn len_list_msg(name: &str, cmp: &str, bound: u64) -> String {
    format!(
        "1 validation error detected: Value at '{name}' failed to satisfy constraint: Member must have length {cmp} {bound}"
    )
}

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

    #[test]
    fn missing_required_member_is_rejected() {
        // CreateCase requires subject + communicationBody.
        let err = validate_input("CreateCase", &json!({ "subject": "x" })).unwrap_err();
        assert!(err.contains("communicationBody"));
    }

    #[test]
    fn valid_create_case_passes() {
        assert!(validate_input(
            "CreateCase",
            &json!({ "subject": "hi", "communicationBody": "please help" })
        )
        .is_ok());
    }

    #[test]
    fn describe_communications_requires_case_id() {
        let err = validate_input("DescribeCommunications", &json!({})).unwrap_err();
        assert!(err.contains("caseId"));
    }

    #[test]
    fn refresh_requires_check_id() {
        let err = validate_input("RefreshTrustedAdvisorCheck", &json!({})).unwrap_err();
        assert!(err.contains("checkId"));
    }

    #[test]
    fn describe_severity_levels_needs_nothing() {
        assert!(validate_input("DescribeSeverityLevels", &json!({})).is_ok());
    }

    #[test]
    fn all_operations_have_constraint_entries() {
        assert!(constraints().contains_key("CreateCase"));
        assert!(constraints().contains_key("DescribeTrustedAdvisorChecks"));
        assert_eq!(constraints().len(), OPERATION_COUNT);
    }
}