Skip to main content

fakecloud_sagemaker/
validate.rs

1//! Model-derived input validation for Amazon SageMaker operations.
2//!
3//! Every SageMaker input member is carried in the awsJson1.1 request body, so
4//! the rules generated from the Smithy model (see `src/generated.rs`) record,
5//! per top-level input member, whether it is `@required` and its `@length` /
6//! `@range` / `@enum` constraints. This validator enforces exactly those,
7//! rejecting a violating request with SageMaker's `ValidationException`.
8//! `@pattern` is intentionally not enforced (it is not part of the constraint
9//! set exercised here and enforcing it would reject otherwise-valid inputs).
10
11use http::StatusCode;
12use serde_json::{Map, Value};
13
14use fakecloud_core::service::AwsServiceError;
15
16use crate::generated::{OpMeta, K};
17
18/// SageMaker's canonical input-validation error. The model does not enumerate a
19/// `ValidationException` shape on most operations, but the live API returns it
20/// for malformed input, and it is registered as a service-wide common error in
21/// the conformance probe.
22pub const VALIDATION_ERROR: &str = "ValidationException";
23
24pub fn invalid(msg: impl Into<String>) -> AwsServiceError {
25    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, VALIDATION_ERROR, msg)
26}
27
28/// Validate a request body against the operation's model-derived constraints.
29pub fn validate(meta: &OpMeta, body: &Map<String, Value>) -> Result<(), AwsServiceError> {
30    for rule in meta.rules {
31        let present = body.get(rule.wire).map(|v| !v.is_null()).unwrap_or(false);
32        if rule.req && !present {
33            return Err(invalid(format!(
34                "Missing required parameter: {}",
35                rule.wire
36            )));
37        }
38        if !present {
39            continue;
40        }
41        let val = &body[rule.wire];
42
43        // String-shaped members: length + enum.
44        if matches!(rule.kind, K::Str) {
45            let s = match val {
46                Value::String(s) => s,
47                _ => {
48                    return Err(invalid(format!("Member {} must be a string.", rule.wire)));
49                }
50            };
51            let len = s.chars().count() as u64;
52            if let Some(min) = rule.min_len {
53                if len < min {
54                    return Err(invalid(format!(
55                        "Member {} is shorter than the minimum length {min}.",
56                        rule.wire
57                    )));
58                }
59            }
60            if let Some(max) = rule.max_len {
61                if len > max {
62                    return Err(invalid(format!(
63                        "Member {} is longer than the maximum length {max}.",
64                        rule.wire
65                    )));
66                }
67            }
68            if !rule.enums.is_empty() && !rule.enums.contains(&s.as_str()) {
69                return Err(invalid(format!(
70                    "Member {} has an invalid enum value.",
71                    rule.wire
72                )));
73            }
74        }
75
76        // Numeric members: range.
77        if matches!(rule.kind, K::Int) && (rule.min_val.is_some() || rule.max_val.is_some()) {
78            if let Some(n) = val.as_i64() {
79                if let Some(min) = rule.min_val {
80                    if n < min {
81                        return Err(invalid(format!(
82                            "Member {} is below the minimum {min}.",
83                            rule.wire
84                        )));
85                    }
86                }
87                if let Some(max) = rule.max_val {
88                    if n > max {
89                        return Err(invalid(format!(
90                            "Member {} is above the maximum {max}.",
91                            rule.wire
92                        )));
93                    }
94                }
95            }
96        }
97    }
98    Ok(())
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::generated::OPS;
105
106    fn meta(op: &str) -> &'static OpMeta {
107        OPS.iter().find(|m| m.op == op).unwrap()
108    }
109
110    #[test]
111    fn rejects_missing_required_member() {
112        // CreateModel requires ModelName.
113        let m = meta("CreateModel");
114        let body = Map::new();
115        assert!(validate(m, &body).is_err());
116    }
117
118    #[test]
119    fn accepts_valid_body() {
120        let m = meta("CreateModel");
121        let mut body = Map::new();
122        body.insert("ModelName".to_string(), Value::String("m".to_string()));
123        assert!(validate(m, &body).is_ok());
124    }
125
126    #[test]
127    fn rejects_invalid_enum() {
128        // CreateApp has an AppType enum.
129        let m = meta("CreateApp");
130        let mut body = Map::new();
131        body.insert("DomainId".to_string(), Value::String("d".to_string()));
132        body.insert(
133            "AppType".to_string(),
134            Value::String("BogusType".to_string()),
135        );
136        body.insert("AppName".to_string(), Value::String("a".to_string()));
137        assert!(validate(m, &body).is_err());
138    }
139}