fakecloud-comprehend 0.41.1

Amazon Comprehend (comprehend) implementation for FakeCloud
Documentation
//! Model-driven input validation for Amazon Comprehend.
//!
//! AWS validates request members against the Smithy model's `required`,
//! `length`, `range`, `enum` and `pattern` constraints and returns an
//! `InvalidRequestException` before any business logic runs. This module
//! reproduces that: the Comprehend 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.

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

use regex::Regex;
use serde_json::Value;

/// The vendored Comprehend Smithy model (byte-for-byte from aws/api-models-aws),
/// embedded so validation needs no runtime file access.
const MODEL_JSON: &str = include_str!("../model.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 = 85;

#[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>>,
    pattern: Option<Regex>,
}

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 transcribe 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/pattern 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);
        }
    }
    if let Some(p) = t.get("smithy.api#pattern").and_then(Value::as_str) {
        // Only patterns the Rust regex engine accepts are enforced; a pattern
        // it can't compile is skipped rather than panicking at startup.
        if c.pattern.is_none() {
            if let Ok(re) = Regex::new(p) {
                c.pattern = Some(re);
            }
        }
    }
}

/// 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`, Comprehend'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
                            ));
                        }
                    }
                    if let Some(re) = &c.pattern {
                        if !re.is_match(s) {
                            return Err(format!(
                                "1 validation error detected: Value '{}' at '{}' failed to satisfy constraint: Member must satisfy regular expression pattern: {}",
                                s, c.name, re.as_str()
                            ));
                        }
                    }
                }
                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() {
        let err = validate_input("DetectSentiment", &json!({})).unwrap_err();
        assert!(err.contains("Text") || err.contains("LanguageCode"));
    }

    #[test]
    fn valid_input_passes() {
        assert!(validate_input(
            "DetectSentiment",
            &json!({ "Text": "hello world", "LanguageCode": "en" })
        )
        .is_ok());
    }

    #[test]
    fn bad_enum_is_rejected() {
        let err = validate_input(
            "DetectSentiment",
            &json!({ "Text": "hi", "LanguageCode": "xx-XX" }),
        )
        .unwrap_err();
        assert!(err.contains("LanguageCode"));
    }

    #[test]
    fn describe_job_requires_job_id() {
        let err = validate_input("DescribeSentimentDetectionJob", &json!({})).unwrap_err();
        assert!(err.contains("JobId"));
    }

    #[test]
    fn all_operations_have_constraint_entries() {
        // Every op in the model is represented (even those with no members).
        assert!(constraints().contains_key("DetectSentiment"));
        assert!(constraints().contains_key("StartSentimentDetectionJob"));
        assert_eq!(constraints().len(), OPERATION_COUNT);
    }
}