1use ciborium::Value as CborValue;
10use serde_json::Value as JsonValue;
11use vantage_expressions::Expression;
12
13#[derive(Clone)]
14pub enum AwsCondition {
15 Eq { field: String, value: CborValue },
17 In {
20 field: String,
21 values: Vec<CborValue>,
22 },
23 Deferred {
27 field: String,
28 source: Expression<CborValue>,
29 },
30}
31
32impl std::fmt::Debug for AwsCondition {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::Eq { field, value } => f
39 .debug_struct("Eq")
40 .field("field", field)
41 .field("value", value)
42 .finish(),
43 Self::In { field, values } => f
44 .debug_struct("In")
45 .field("field", field)
46 .field("values", values)
47 .finish(),
48 Self::Deferred { field, source } => f
49 .debug_struct("Deferred")
50 .field("field", field)
51 .field("source.template", &source.template)
52 .field("source.params", &source.parameters.len())
53 .finish(),
54 }
55 }
56}
57
58impl AwsCondition {
59 pub fn eq(field: impl Into<String>, value: impl Into<CborValue>) -> Self {
60 Self::Eq {
61 field: field.into(),
62 value: value.into(),
63 }
64 }
65
66 pub fn in_<I, V>(field: impl Into<String>, values: I) -> Self
67 where
68 I: IntoIterator<Item = V>,
69 V: Into<CborValue>,
70 {
71 Self::In {
72 field: field.into(),
73 values: values.into_iter().map(Into::into).collect(),
74 }
75 }
76
77 pub fn field(&self) -> &str {
78 match self {
79 Self::Eq { field, .. } | Self::In { field, .. } | Self::Deferred { field, .. } => field,
80 }
81 }
82}
83
84pub fn eq(field: impl Into<String>, value: impl Into<CborValue>) -> AwsCondition {
91 AwsCondition::eq(field, value)
92}
93
94pub fn in_<I, V>(field: impl Into<String>, values: I) -> AwsCondition
98where
99 I: IntoIterator<Item = V>,
100 V: Into<CborValue>,
101{
102 AwsCondition::in_(field, values)
103}
104
105fn resolved_pairs(conditions: &[AwsCondition]) -> vantage_core::Result<Vec<(String, CborValue)>> {
113 let mut out = Vec::with_capacity(conditions.len());
114 for cond in conditions {
115 match cond {
116 AwsCondition::Eq { field, value } => {
117 out.push((field.clone(), value.clone()));
118 }
119 AwsCondition::In { field, values } => match values.as_slice() {
120 [single] => out.push((field.clone(), single.clone())),
121 [] => {
122 return Err(vantage_core::error!(
123 "AwsCondition::In with zero values is not representable",
124 field = field.as_str()
125 ));
126 }
127 _ => {
128 return Err(vantage_core::error!(
129 "AwsCondition::In with more than one value is not supported \
130 by AWS — relations must traverse from a single parent",
131 field = field.as_str(),
132 count = values.len()
133 ));
134 }
135 },
136 AwsCondition::Deferred { field, .. } => {
137 return Err(vantage_core::error!(
138 "Internal: Deferred condition reached body builder unresolved \
139 — AwsAccount::resolve_conditions should have materialised it",
140 field = field.as_str()
141 ));
142 }
143 }
144 }
145 Ok(out)
146}
147
148pub(crate) fn build_json1_body(
151 conditions: &[AwsCondition],
152) -> vantage_core::Result<serde_json::Map<String, JsonValue>> {
153 let pairs = resolved_pairs(conditions)?;
154 let mut body = serde_json::Map::new();
155 for (field, value) in pairs {
156 body.insert(field, cbor_to_json(&value));
157 }
158 Ok(body)
159}
160
161pub(crate) fn build_query_form(
167 conditions: &[AwsCondition],
168) -> vantage_core::Result<Vec<(String, String)>> {
169 let pairs = resolved_pairs(conditions)?;
170 Ok(pairs
171 .into_iter()
172 .map(|(k, v)| (k, cbor_to_string(&v)))
173 .collect())
174}
175
176fn cbor_to_string(v: &CborValue) -> String {
177 match v {
178 CborValue::Text(s) => s.clone(),
179 CborValue::Integer(i) => {
180 let n: i128 = (*i).into();
181 n.to_string()
182 }
183 CborValue::Float(f) => f.to_string(),
184 CborValue::Bool(b) => b.to_string(),
185 CborValue::Null => String::new(),
186 other => cbor_to_json(other).to_string(),
187 }
188}
189
190fn cbor_to_json(v: &CborValue) -> JsonValue {
195 v.deserialized::<JsonValue>().unwrap_or(JsonValue::Null)
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use serde_json::json;
202
203 #[test]
204 fn eq_folds_into_body() {
205 let conds = [eq("logGroupNamePrefix", "/aws/lambda/")];
206 let body = build_json1_body(&conds).unwrap();
207 assert_eq!(body["logGroupNamePrefix"], json!("/aws/lambda/"));
208 }
209
210 #[test]
211 fn single_element_in_collapses_to_eq() {
212 let conds = [in_(
213 "logGroupName",
214 vec![CborValue::from("/aws/lambda/foo")],
215 )];
216 let body = build_json1_body(&conds).unwrap();
217 assert_eq!(body["logGroupName"], json!("/aws/lambda/foo"));
218 }
219
220 #[test]
221 fn multi_element_in_errors() {
222 let conds = [in_(
223 "logGroupName",
224 vec![CborValue::from("a"), CborValue::from("b")],
225 )];
226 let err = build_json1_body(&conds).unwrap_err();
227 assert!(format!("{err}").contains("more than one value"));
228 }
229
230 #[test]
231 fn empty_in_errors() {
232 let conds = [AwsCondition::In {
233 field: "x".into(),
234 values: vec![],
235 }];
236 assert!(build_json1_body(&conds).is_err());
237 }
238
239 #[test]
240 fn deferred_in_build_body_is_internal_error() {
241 let conds = [AwsCondition::Deferred {
245 field: "x".into(),
246 source: Expression::new("noop", vec![]),
247 }];
248 let err = build_json1_body(&conds).unwrap_err();
249 assert!(format!("{err}").contains("Deferred"));
250 }
251
252 #[test]
253 fn multiple_eqs_compose() {
254 let conds = [
255 eq("logGroupName", "/aws/lambda/foo"),
256 eq("startTime", 1_700_000_000_000i64),
257 ];
258 let body = build_json1_body(&conds).unwrap();
259 assert_eq!(body["logGroupName"], json!("/aws/lambda/foo"));
260 assert_eq!(body["startTime"], json!(1_700_000_000_000i64));
261 }
262
263 #[test]
264 fn query_form_renders_strings_and_numbers() {
265 let conds = [eq("UserName", "alice"), eq("MaxItems", 50i64)];
266 let form = build_query_form(&conds).unwrap();
267 assert_eq!(
268 form,
269 vec![
270 ("UserName".to_string(), "alice".to_string()),
271 ("MaxItems".to_string(), "50".to_string()),
272 ]
273 );
274 }
275}