Skip to main content

fakecloud_ec2/
service_helpers.rs

1//! Shared EC2 request-parsing and error helpers.
2//!
3//! EC2's query encoding uses 1-based indexed list members (`ResourceId.1`,
4//! `Tag.2.Key`) and a uniform `Filter.N.Name` / `Filter.N.Value.M` shape on
5//! every `Describe*` operation. These helpers parse those shapes once so every
6//! resource-family batch reuses them rather than re-deriving the indexing.
7
8use std::collections::HashMap;
9
10use http::StatusCode;
11
12use fakecloud_core::service::AwsServiceError;
13
14/// An EC2 `Filter.N` entry: a name and one or more accepted values (OR within a
15/// filter, AND across filters — AWS semantics).
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct Filter {
18    pub name: String,
19    pub values: Vec<String>,
20}
21
22/// Generate an EC2 resource id: `<prefix>-<17 lowercase hex>`, matching the
23/// modern long-id format (e.g. `vpc-0a1b2c3d4e5f67890`).
24pub fn gen_id(prefix: &str) -> String {
25    let hex = uuid::Uuid::new_v4().simple().to_string();
26    format!("{prefix}-{}", &hex[..17])
27}
28
29/// `InvalidParameterValue` — the catch-all 400 for bad EC2 input.
30pub fn invalid_parameter_value(message: impl Into<String>) -> AwsServiceError {
31    AwsServiceError::aws_error(
32        StatusCode::BAD_REQUEST,
33        "InvalidParameterValue",
34        message.into(),
35    )
36}
37
38/// `MissingParameter` — a required parameter was absent.
39pub fn missing_parameter(name: &str) -> AwsServiceError {
40    AwsServiceError::aws_error(
41        StatusCode::BAD_REQUEST,
42        "MissingParameter",
43        format!("The request must contain the parameter {name}"),
44    )
45}
46
47/// An EC2 `Invalid<Resource>.NotFound`-style error (HTTP 400, matching AWS).
48pub fn not_found(code: &str, id: &str) -> AwsServiceError {
49    AwsServiceError::aws_error(
50        StatusCode::BAD_REQUEST,
51        code,
52        format!("The ID '{id}' does not exist"),
53    )
54}
55
56/// Require a non-empty scalar parameter, else `MissingParameter`. Omitting a
57/// required scalar is wire-observable, so the conformance harness generates a
58/// negative variant for it — handlers must reject it.
59pub fn require(params: &HashMap<String, String>, key: &str) -> Result<String, AwsServiceError> {
60    params
61        .get(key)
62        .filter(|v| !v.is_empty())
63        .cloned()
64        .ok_or_else(|| missing_parameter(key))
65}
66
67/// Require a structure member to be present, identified by any wire param
68/// under `{prefix}.` (e.g. `InstanceTagAttribute.IncludeAllTagsOfInstance`).
69/// Omitting a required *structure* is wire-observable, so the harness emits a
70/// `negative_omit_<Struct>` variant — handlers must reject it.
71pub fn require_struct(
72    params: &HashMap<String, String>,
73    prefix: &str,
74) -> Result<(), AwsServiceError> {
75    let pat = format!("{prefix}.");
76    if params.keys().any(|k| k.starts_with(&pat)) {
77        Ok(())
78    } else {
79        Err(missing_parameter(prefix))
80    }
81}
82
83/// Reject a present-but-invalid enum value (the harness's
84/// `negative_invalid_enum_*` variant). Absent is allowed here — required-ness
85/// is enforced separately via [`require`].
86pub fn validate_enum(
87    params: &HashMap<String, String>,
88    key: &str,
89    allowed: &[&str],
90) -> Result<(), AwsServiceError> {
91    if let Some(v) = params.get(key).filter(|v| !v.is_empty()) {
92        if !allowed.contains(&v.as_str()) {
93            return Err(invalid_parameter_value(format!(
94                "Invalid value '{v}' for {key}"
95            )));
96        }
97    }
98    Ok(())
99}
100
101/// Reject an out-of-range `MaxResults` (the harness's `negative_below_min` /
102/// `negative_above_max` variants). EC2 describe pages bound MaxResults to
103/// [5, 1000] unless documented otherwise.
104pub fn validate_max_results(
105    params: &HashMap<String, String>,
106    min: i64,
107    max: i64,
108) -> Result<(), AwsServiceError> {
109    if let Some(v) = params.get("MaxResults").filter(|v| !v.is_empty()) {
110        if let Ok(n) = v.parse::<i64>() {
111            if n < min || n > max {
112                return Err(invalid_parameter_value(format!(
113                    "MaxResults must be between {min} and {max}"
114                )));
115            }
116        }
117    }
118    Ok(())
119}
120
121/// Reject a present integer parameter outside `[min, max]` (the harness's
122/// `negative_below_min_*` / `negative_above_max_*` variants for `@range`
123/// members like `PrivateIpAddressCount` or `MaxDrainDurationSeconds`).
124pub fn validate_int_range(
125    params: &HashMap<String, String>,
126    key: &str,
127    min: i64,
128    max: i64,
129) -> Result<(), AwsServiceError> {
130    if let Some(v) = params.get(key).filter(|v| !v.is_empty()) {
131        if let Ok(n) = v.parse::<i64>() {
132            if n < min || n > max {
133                return Err(invalid_parameter_value(format!(
134                    "{key} must be between {min} and {max}"
135                )));
136            }
137        }
138    }
139    Ok(())
140}
141
142/// Reject a present parameter whose length is outside `[min, max]` (the
143/// harness's `negative_too_short_*` / `negative_too_long_*` variants for
144/// `@length`-constrained members such as a bounded `NextToken`).
145pub fn validate_length(
146    params: &HashMap<String, String>,
147    key: &str,
148    min: usize,
149    max: usize,
150) -> Result<(), AwsServiceError> {
151    if let Some(v) = params.get(key) {
152        let n = v.chars().count();
153        if n < min || n > max {
154            return Err(invalid_parameter_value(format!(
155                "{key} length must be between {min} and {max}"
156            )));
157        }
158    }
159    Ok(())
160}
161
162/// Collect a 1-based indexed list, e.g. `ResourceId.1`, `ResourceId.2`, ….
163///
164/// EC2 list members are contiguous from index 1; collection stops at the first
165/// missing index. Empty values terminate the list too (matching how the SDKs
166/// never emit a gap).
167pub fn indexed_list(params: &HashMap<String, String>, prefix: &str) -> Vec<String> {
168    let mut out = Vec::new();
169    let mut i = 1usize;
170    loop {
171        let key = format!("{prefix}.{i}");
172        match params.get(&key) {
173            Some(v) if !v.is_empty() => out.push(v.clone()),
174            _ => break,
175        }
176        i += 1;
177    }
178    out
179}
180
181/// Parse `Filter.N.Name` + `Filter.N.Value.M` into [`Filter`] entries.
182pub fn parse_filters(params: &HashMap<String, String>) -> Vec<Filter> {
183    let mut out = Vec::new();
184    let mut i = 1usize;
185    loop {
186        let name_key = format!("Filter.{i}.Name");
187        let Some(name) = params.get(&name_key).filter(|v| !v.is_empty()) else {
188            break;
189        };
190        let values = indexed_list(params, &format!("Filter.{i}.Value"));
191        out.push(Filter {
192            name: name.clone(),
193            values,
194        });
195        i += 1;
196    }
197    out
198}
199
200/// Parse `{prefix}.N.Key` + `{prefix}.N.Value` tag pairs (the request shape for
201/// `CreateTags`/`DeleteTags` and `TagSpecification.N.Tag.M`).
202///
203/// The value is `None` only when the `Value` parameter is *absent* — for
204/// `DeleteTags` that means "remove this key regardless of value". A *present*
205/// `Value` (including an explicit empty string `Value=`) is preserved as
206/// `Some(value)` so DeleteTags can match the empty-value tag specifically
207/// rather than collapsing it into a key-only delete.
208pub fn parse_tag_pairs(
209    params: &HashMap<String, String>,
210    prefix: &str,
211) -> Vec<(String, Option<String>)> {
212    let mut out = Vec::new();
213    let mut i = 1usize;
214    loop {
215        let key_param = format!("{prefix}.{i}.Key");
216        let Some(key) = params.get(&key_param).filter(|v| !v.is_empty()) else {
217            break;
218        };
219        let value = params.get(&format!("{prefix}.{i}.Value")).cloned();
220        out.push((key.clone(), value));
221        i += 1;
222    }
223    out
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn p(pairs: &[(&str, &str)]) -> HashMap<String, String> {
231        pairs
232            .iter()
233            .map(|(k, v)| (k.to_string(), v.to_string()))
234            .collect()
235    }
236
237    #[test]
238    fn indexed_list_collects_contiguous_then_stops() {
239        let params = p(&[("ResourceId.1", "vpc-1"), ("ResourceId.2", "vpc-2")]);
240        assert_eq!(indexed_list(&params, "ResourceId"), vec!["vpc-1", "vpc-2"]);
241    }
242
243    #[test]
244    fn indexed_list_stops_at_gap() {
245        let params = p(&[("ResourceId.1", "vpc-1"), ("ResourceId.3", "vpc-3")]);
246        assert_eq!(indexed_list(&params, "ResourceId"), vec!["vpc-1"]);
247    }
248
249    #[test]
250    fn parse_filters_groups_name_and_values() {
251        let params = p(&[
252            ("Filter.1.Name", "resource-id"),
253            ("Filter.1.Value.1", "vpc-1"),
254            ("Filter.1.Value.2", "vpc-2"),
255            ("Filter.2.Name", "key"),
256            ("Filter.2.Value.1", "Name"),
257        ]);
258        let filters = parse_filters(&params);
259        assert_eq!(filters.len(), 2);
260        assert_eq!(
261            filters[0],
262            Filter {
263                name: "resource-id".into(),
264                values: vec!["vpc-1".into(), "vpc-2".into()]
265            }
266        );
267        assert_eq!(
268            filters[1],
269            Filter {
270                name: "key".into(),
271                values: vec!["Name".into()]
272            }
273        );
274    }
275
276    #[test]
277    fn parse_tag_pairs_handles_optional_value() {
278        let params = p(&[
279            ("Tag.1.Key", "Name"),
280            ("Tag.1.Value", "web"),
281            ("Tag.2.Key", "env"),
282        ]);
283        let tags = parse_tag_pairs(&params, "Tag");
284        assert_eq!(
285            tags,
286            vec![("Name".into(), Some("web".into())), ("env".into(), None)]
287        );
288    }
289
290    #[test]
291    fn parse_tag_pairs_distinguishes_empty_value_from_absent() {
292        // Present-but-empty `Value=` -> Some(""), absent `Value` -> None.
293        // DeleteTags relies on this: `Value=` deletes only the empty-value tag,
294        // while an absent value deletes the key regardless of value.
295        let params = p(&[("Tag.1.Key", "a"), ("Tag.1.Value", ""), ("Tag.2.Key", "b")]);
296        let tags = parse_tag_pairs(&params, "Tag");
297        assert_eq!(
298            tags,
299            vec![("a".into(), Some("".into())), ("b".into(), None)]
300        );
301    }
302}