Skip to main content

fakecloud_iot/
validate.rs

1//! Model-derived input validation for AWS IoT Core operations.
2//!
3//! The rules are generated from the Smithy model (see `src/generated.rs`):
4//! for every top-level input member the generator records its wire location
5//! (`@httpLabel` / `@httpQuery` / `@httpHeader` / body), whether it is
6//! `@required`, and its `@length` / `@range` / `@enum` constraints. This
7//! validator enforces exactly those constraints — mirroring the model — so a
8//! request that satisfies the model is accepted and one that violates a
9//! declared constraint is rejected with the operation's declared client error
10//! (`InvalidRequestException`, falling back to `ValidationException` or the
11//! operation's first declared error when `InvalidRequestException` is not in
12//! its Smithy `errors` list). `@pattern` is intentionally *not* enforced:
13//! IoT patterns are not part of the constraint set exercised here and
14//! enforcing them would reject otherwise-valid inputs.
15
16use std::collections::HashMap;
17
18use http::{HeaderMap, StatusCode};
19use serde_json::{Map, Value};
20
21use fakecloud_core::service::AwsServiceError;
22
23use crate::generated::{OpMeta, Src, K};
24
25/// Pick the client-error code this operation should use for an input-validation
26/// failure: `InvalidRequestException` when declared, else `ValidationException`,
27/// else the operation's first declared error, else `InvalidRequestException`.
28pub fn validation_error_code(meta: &OpMeta) -> &'static str {
29    if meta.errors.contains(&"InvalidRequestException") {
30        "InvalidRequestException"
31    } else if meta.errors.contains(&"ValidationException") {
32        "ValidationException"
33    } else if let Some(first) = meta.errors.first() {
34        first
35    } else {
36        "InvalidRequestException"
37    }
38}
39
40pub fn invalid(meta: &OpMeta, msg: impl Into<String>) -> AwsServiceError {
41    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, validation_error_code(meta), msg)
42}
43
44/// The request inputs, already split by wire location.
45pub struct Inputs<'a> {
46    pub labels: &'a HashMap<String, String>,
47    pub query: &'a [(String, String)],
48    pub headers: &'a HeaderMap,
49    pub body: &'a Map<String, Value>,
50}
51
52impl Inputs<'_> {
53    fn query_get(&self, key: &str) -> Option<&str> {
54        self.query
55            .iter()
56            .find(|(k, _)| k == key)
57            .map(|(_, v)| v.as_str())
58    }
59    fn header_get(&self, key: &str) -> Option<&str> {
60        self.headers.get(key).and_then(|v| v.to_str().ok())
61    }
62}
63
64/// Validate a request against the operation's model-derived constraints.
65pub fn validate(meta: &OpMeta, inputs: &Inputs) -> Result<(), AwsServiceError> {
66    // A required `@httpPayload` member is the entire request body; enforce its
67    // presence (an absent / empty body is a client error). Payload members are
68    // not part of the per-member `rules`, so this is checked separately.
69    if meta.req_payload && inputs.body.is_empty() {
70        return Err(invalid(meta, "Missing required request payload."));
71    }
72
73    for rule in meta.rules {
74        // Resolve the member's string value (label/query/header) or JSON value
75        // (body), and whether it is present at all.
76        let string_val: Option<String> = match rule.src {
77            Src::Label => inputs.labels.get(rule.wire).cloned(),
78            Src::Query => inputs.query_get(rule.wire).map(str::to_string),
79            Src::Header => inputs.header_get(rule.wire).map(str::to_string),
80            Src::Body => None,
81        };
82        let body_val: Option<&Value> = match rule.src {
83            Src::Body => inputs.body.get(rule.wire),
84            _ => None,
85        };
86        let present = string_val.is_some() || body_val.is_some();
87
88        if rule.req && !present {
89            return Err(invalid(
90                meta,
91                format!("Missing required request parameter: {}", rule.wire),
92            ));
93        }
94        if !present {
95            continue;
96        }
97
98        // String-shaped members: length + enum. For body members, extract the
99        // string when the JSON value is a string (the wire form of a Smithy
100        // string / enum). A non-string JSON value for a string member is a
101        // client error too.
102        if matches!(rule.kind, K::Str) {
103            let s: Option<String> = match &string_val {
104                Some(s) => Some(s.clone()),
105                None => match body_val {
106                    Some(Value::String(s)) => Some(s.clone()),
107                    Some(Value::Null) => None,
108                    Some(_other) => {
109                        return Err(invalid(
110                            meta,
111                            format!("Member {} must be a string.", rule.wire),
112                        ));
113                    }
114                    None => None,
115                },
116            };
117            if let Some(s) = s {
118                let len = s.chars().count() as u64;
119                if let Some(min) = rule.min_len {
120                    if len < min {
121                        return Err(invalid(
122                            meta,
123                            format!(
124                                "Member {} is shorter than the minimum length {min}.",
125                                rule.wire
126                            ),
127                        ));
128                    }
129                }
130                if let Some(max) = rule.max_len {
131                    if len > max {
132                        return Err(invalid(
133                            meta,
134                            format!(
135                                "Member {} is longer than the maximum length {max}.",
136                                rule.wire
137                            ),
138                        ));
139                    }
140                }
141                if !rule.enums.is_empty() && !rule.enums.contains(&s.as_str()) {
142                    return Err(invalid(
143                        meta,
144                        format!("Member {} has an invalid enum value.", rule.wire),
145                    ));
146                }
147            }
148        }
149
150        // Numeric members: range.
151        if matches!(rule.kind, K::Int) && (rule.min_val.is_some() || rule.max_val.is_some()) {
152            let n: Option<i64> = match &string_val {
153                Some(s) => s.parse::<i64>().ok(),
154                None => body_val.and_then(Value::as_i64),
155            };
156            if let Some(n) = n {
157                if let Some(min) = rule.min_val {
158                    if n < min {
159                        return Err(invalid(
160                            meta,
161                            format!("Member {} is below the minimum {min}.", rule.wire),
162                        ));
163                    }
164                }
165                if let Some(max) = rule.max_val {
166                    if n > max {
167                        return Err(invalid(
168                            meta,
169                            format!("Member {} is above the maximum {max}.", rule.wire),
170                        ));
171                    }
172                }
173            }
174        }
175    }
176    Ok(())
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::generated::OPS;
183
184    fn meta(op: &str) -> &'static OpMeta {
185        OPS.iter().find(|m| m.op == op).unwrap()
186    }
187    fn inputs<'a>(
188        labels: &'a HashMap<String, String>,
189        query: &'a [(String, String)],
190        headers: &'a HeaderMap,
191        body: &'a Map<String, Value>,
192    ) -> Inputs<'a> {
193        Inputs {
194            labels,
195            query,
196            headers,
197            body,
198        }
199    }
200
201    #[test]
202    fn rejects_missing_required_body_member() {
203        // AttachPolicy requires `target` in the body.
204        let m = meta("AttachPolicy");
205        let mut labels = HashMap::new();
206        labels.insert("policyName".to_string(), "p".to_string());
207        let body = Map::new();
208        let hm = HeaderMap::new();
209        let inp = inputs(&labels, &[], &hm, &body);
210        assert!(validate(m, &inp).is_err());
211    }
212
213    #[test]
214    fn accepts_valid_body() {
215        let m = meta("AttachPolicy");
216        let mut labels = HashMap::new();
217        labels.insert("policyName".to_string(), "p".to_string());
218        let mut body = Map::new();
219        body.insert("target".to_string(), Value::String("arn:x".to_string()));
220        let hm = HeaderMap::new();
221        let inp = inputs(&labels, &[], &hm, &body);
222        assert!(validate(m, &inp).is_ok());
223    }
224
225    #[test]
226    fn rejects_too_long_label() {
227        // certificateId is fixed length 64.
228        let m = meta("DescribeCertificate");
229        let mut labels = HashMap::new();
230        labels.insert("certificateId".to_string(), "a".repeat(65));
231        let body = Map::new();
232        let hm = HeaderMap::new();
233        let inp = inputs(&labels, &[], &hm, &body);
234        assert!(validate(m, &inp).is_err());
235    }
236
237    #[test]
238    fn accepts_boundary_length() {
239        let m = meta("DescribeCertificate");
240        let mut labels = HashMap::new();
241        labels.insert("certificateId".to_string(), "a".repeat(64));
242        let body = Map::new();
243        let hm = HeaderMap::new();
244        let inp = inputs(&labels, &[], &hm, &body);
245        assert!(validate(m, &inp).is_ok());
246    }
247}