Skip to main content

fakecloud_cloudformation/
xml_responses.rs

1use crate::state::{Stack, StackResource};
2
3use fakecloud_aws::xml::xml_escape;
4
5pub fn create_stack_response(stack_id: &str, request_id: &str) -> String {
6    format!(
7        r#"<CreateStackResponse xmlns="http://cloudformation.amazonaws.com/doc/2010-05-15/">
8  <CreateStackResult>
9    <StackId>{stack_id}</StackId>
10  </CreateStackResult>
11  <ResponseMetadata>
12    <RequestId>{request_id}</RequestId>
13  </ResponseMetadata>
14</CreateStackResponse>"#,
15        stack_id = xml_escape(stack_id),
16        request_id = xml_escape(request_id),
17    )
18}
19
20pub fn update_stack_response(stack_id: &str, request_id: &str) -> String {
21    format!(
22        r#"<UpdateStackResponse xmlns="http://cloudformation.amazonaws.com/doc/2010-05-15/">
23  <UpdateStackResult>
24    <StackId>{stack_id}</StackId>
25  </UpdateStackResult>
26  <ResponseMetadata>
27    <RequestId>{request_id}</RequestId>
28  </ResponseMetadata>
29</UpdateStackResponse>"#,
30        stack_id = xml_escape(stack_id),
31        request_id = xml_escape(request_id),
32    )
33}
34
35pub fn delete_stack_response(request_id: &str) -> String {
36    format!(
37        r#"<DeleteStackResponse xmlns="http://cloudformation.amazonaws.com/doc/2010-05-15/">
38  <ResponseMetadata>
39    <RequestId>{request_id}</RequestId>
40  </ResponseMetadata>
41</DeleteStackResponse>"#,
42        request_id = xml_escape(request_id),
43    )
44}
45
46pub fn describe_stacks_response(stacks: &[Stack], request_id: &str) -> String {
47    let members: String = stacks
48        .iter()
49        .map(stack_member_xml)
50        .collect::<Vec<_>>()
51        .join("\n");
52
53    format!(
54        r#"<DescribeStacksResponse xmlns="http://cloudformation.amazonaws.com/doc/2010-05-15/">
55  <DescribeStacksResult>
56    <Stacks>
57{members}
58    </Stacks>
59  </DescribeStacksResult>
60  <ResponseMetadata>
61    <RequestId>{request_id}</RequestId>
62  </ResponseMetadata>
63</DescribeStacksResponse>"#,
64        request_id = xml_escape(request_id),
65    )
66}
67
68fn stack_member_xml(stack: &Stack) -> String {
69    let tags_xml = if stack.tags.is_empty() {
70        String::new()
71    } else {
72        let tags: String = stack
73            .tags
74            .iter()
75            .map(|(k, v)| {
76                format!(
77                    "          <member>\n            <Key>{}</Key>\n            <Value>{}</Value>\n          </member>",
78                    xml_escape(k),
79                    xml_escape(v),
80                )
81            })
82            .collect::<Vec<_>>()
83            .join("\n");
84        format!("\n        <Tags>\n{tags}\n        </Tags>")
85    };
86
87    let params_xml = if stack.parameters.is_empty() {
88        String::new()
89    } else {
90        let params: String = stack
91            .parameters
92            .iter()
93            .map(|(k, v)| {
94                format!(
95                    "          <member>\n            <ParameterKey>{}</ParameterKey>\n            <ParameterValue>{}</ParameterValue>\n          </member>",
96                    xml_escape(k),
97                    xml_escape(v),
98                )
99            })
100            .collect::<Vec<_>>()
101            .join("\n");
102        format!("\n        <Parameters>\n{params}\n        </Parameters>")
103    };
104
105    let description_xml = stack
106        .description
107        .as_ref()
108        .map(|d| format!("\n        <Description>{}</Description>", xml_escape(d)))
109        .unwrap_or_default();
110
111    let notification_arns_xml = if stack.notification_arns.is_empty() {
112        String::new()
113    } else {
114        let members: String = stack
115            .notification_arns
116            .iter()
117            .map(|arn| format!("          <member>{}</member>", xml_escape(arn)))
118            .collect::<Vec<_>>()
119            .join("\n");
120        format!("\n        <NotificationARNs>\n{members}\n        </NotificationARNs>")
121    };
122
123    let outputs_xml = if stack.outputs.is_empty() {
124        String::new()
125    } else {
126        let members: String = stack
127            .outputs
128            .iter()
129            .map(|o| {
130                let desc = o
131                    .description
132                    .as_ref()
133                    .map(|d| format!("\n            <Description>{}</Description>", xml_escape(d)))
134                    .unwrap_or_default();
135                let export = o
136                    .export_name
137                    .as_ref()
138                    .map(|n| format!("\n            <ExportName>{}</ExportName>", xml_escape(n)))
139                    .unwrap_or_default();
140                format!(
141                    "          <member>\n            <OutputKey>{}</OutputKey>\n            <OutputValue>{}</OutputValue>{}{}\n          </member>",
142                    xml_escape(&o.key),
143                    xml_escape(&o.value),
144                    desc,
145                    export,
146                )
147            })
148            .collect::<Vec<_>>()
149            .join("\n");
150        format!("\n        <Outputs>\n{members}\n        </Outputs>")
151    };
152
153    format!(
154        r#"      <member>
155        <StackName>{name}</StackName>
156        <StackId>{id}</StackId>
157        <StackStatus>{status}</StackStatus>
158        <CreationTime>{created}</CreationTime>{description_xml}{tags_xml}{params_xml}{notification_arns_xml}{outputs_xml}
159      </member>"#,
160        name = xml_escape(&stack.name),
161        id = xml_escape(&stack.stack_id),
162        status = xml_escape(&stack.status),
163        created = stack.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
164    )
165}
166
167pub fn list_stacks_response(stacks: &[Stack], request_id: &str) -> String {
168    let summaries: String = stacks
169        .iter()
170        .map(|s| {
171            format!(
172                r#"      <member>
173        <StackName>{name}</StackName>
174        <StackId>{id}</StackId>
175        <StackStatus>{status}</StackStatus>
176        <CreationTime>{created}</CreationTime>
177      </member>"#,
178                name = xml_escape(&s.name),
179                id = xml_escape(&s.stack_id),
180                status = xml_escape(&s.status),
181                created = s.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
182            )
183        })
184        .collect::<Vec<_>>()
185        .join("\n");
186
187    format!(
188        r#"<ListStacksResponse xmlns="http://cloudformation.amazonaws.com/doc/2010-05-15/">
189  <ListStacksResult>
190    <StackSummaries>
191{summaries}
192    </StackSummaries>
193  </ListStacksResult>
194  <ResponseMetadata>
195    <RequestId>{request_id}</RequestId>
196  </ResponseMetadata>
197</ListStacksResponse>"#,
198        request_id = xml_escape(request_id),
199    )
200}
201
202pub fn list_stack_resources_response(resources: &[StackResource], request_id: &str) -> String {
203    let summaries: String = resources
204        .iter()
205        .map(stack_resource_summary_xml)
206        .collect::<Vec<_>>()
207        .join("\n");
208
209    format!(
210        r#"<ListStackResourcesResponse xmlns="http://cloudformation.amazonaws.com/doc/2010-05-15/">
211  <ListStackResourcesResult>
212    <StackResourceSummaries>
213{summaries}
214    </StackResourceSummaries>
215  </ListStackResourcesResult>
216  <ResponseMetadata>
217    <RequestId>{request_id}</RequestId>
218  </ResponseMetadata>
219</ListStackResourcesResponse>"#,
220        request_id = xml_escape(request_id),
221    )
222}
223
224fn stack_resource_summary_xml(resource: &StackResource) -> String {
225    format!(
226        r#"      <member>
227        <LogicalResourceId>{logical_id}</LogicalResourceId>
228        <PhysicalResourceId>{physical_id}</PhysicalResourceId>
229        <ResourceType>{resource_type}</ResourceType>
230        <ResourceStatus>{status}</ResourceStatus>
231      </member>"#,
232        logical_id = xml_escape(&resource.logical_id),
233        physical_id = xml_escape(&resource.physical_id),
234        resource_type = xml_escape(&resource.resource_type),
235        status = xml_escape(&resource.status),
236    )
237}
238
239pub fn describe_stack_resources_response(
240    resources: &[StackResource],
241    stack_name: &str,
242    request_id: &str,
243) -> String {
244    let members: String = resources
245        .iter()
246        .map(|r| {
247            format!(
248                r#"      <member>
249        <StackName>{stack_name}</StackName>
250        <LogicalResourceId>{logical_id}</LogicalResourceId>
251        <PhysicalResourceId>{physical_id}</PhysicalResourceId>
252        <ResourceType>{resource_type}</ResourceType>
253        <ResourceStatus>{status}</ResourceStatus>
254      </member>"#,
255                stack_name = xml_escape(stack_name),
256                logical_id = xml_escape(&r.logical_id),
257                physical_id = xml_escape(&r.physical_id),
258                resource_type = xml_escape(&r.resource_type),
259                status = xml_escape(&r.status),
260            )
261        })
262        .collect::<Vec<_>>()
263        .join("\n");
264
265    format!(
266        r#"<DescribeStackResourcesResponse xmlns="http://cloudformation.amazonaws.com/doc/2010-05-15/">
267  <DescribeStackResourcesResult>
268    <StackResources>
269{members}
270    </StackResources>
271  </DescribeStackResourcesResult>
272  <ResponseMetadata>
273    <RequestId>{request_id}</RequestId>
274  </ResponseMetadata>
275</DescribeStackResourcesResponse>"#,
276        request_id = xml_escape(request_id),
277    )
278}
279
280pub fn get_template_response(template_body: &str, request_id: &str) -> String {
281    format!(
282        r#"<GetTemplateResponse xmlns="http://cloudformation.amazonaws.com/doc/2010-05-15/">
283  <GetTemplateResult>
284    <TemplateBody>{template_body}</TemplateBody>
285  </GetTemplateResult>
286  <ResponseMetadata>
287    <RequestId>{request_id}</RequestId>
288  </ResponseMetadata>
289</GetTemplateResponse>"#,
290        template_body = xml_escape(template_body),
291        request_id = xml_escape(request_id),
292    )
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use chrono::Utc;
299    use fakecloud_aws::arn::Arn;
300    use std::collections::BTreeMap;
301
302    fn make_stack(name: &str) -> Stack {
303        Stack {
304            name: name.to_string(),
305            stack_id: Arn::new(
306                "cloudformation",
307                "us-east-1",
308                "123456789012",
309                &format!("stack/{name}/abc123"),
310            )
311            .to_string(),
312            template: "{}".to_string(),
313            status: "CREATE_COMPLETE".to_string(),
314            resources: vec![],
315            parameters: BTreeMap::new(),
316            tags: BTreeMap::new(),
317            created_at: Utc::now(),
318            updated_at: None,
319            description: None,
320            notification_arns: vec![],
321            outputs: vec![],
322        }
323    }
324
325    fn make_resource(logical_id: &str, resource_type: &str) -> StackResource {
326        StackResource {
327            logical_id: logical_id.to_string(),
328            physical_id: format!("phys-{logical_id}"),
329            resource_type: resource_type.to_string(),
330            status: "CREATE_COMPLETE".to_string(),
331            service_token: None,
332            attributes: std::collections::BTreeMap::new(),
333        }
334    }
335
336    #[test]
337    fn create_stack_response_contains_stack_id() {
338        let xml = create_stack_response("stack-123", "req-1");
339        assert!(xml.contains("<StackId>stack-123</StackId>"));
340        assert!(xml.contains("<RequestId>req-1</RequestId>"));
341        assert!(xml.contains("CreateStackResponse"));
342    }
343
344    #[test]
345    fn update_stack_response_contains_stack_id() {
346        let xml = update_stack_response("stack-456", "req-2");
347        assert!(xml.contains("<StackId>stack-456</StackId>"));
348        assert!(xml.contains("UpdateStackResponse"));
349    }
350
351    #[test]
352    fn delete_stack_response_format() {
353        let xml = delete_stack_response("req-3");
354        assert!(xml.contains("<RequestId>req-3</RequestId>"));
355        assert!(xml.contains("DeleteStackResponse"));
356    }
357
358    #[test]
359    fn describe_stacks_response_lists_stacks() {
360        let s1 = make_stack("my-stack");
361        let xml = describe_stacks_response(&[s1], "req-4");
362        assert!(xml.contains("<StackName>my-stack</StackName>"));
363        assert!(xml.contains("CREATE_COMPLETE"));
364        assert!(xml.contains("DescribeStacksResponse"));
365    }
366
367    #[test]
368    fn describe_stacks_with_tags_and_params() {
369        let mut stack = make_stack("tagged-stack");
370        stack.tags.insert("env".to_string(), "prod".to_string());
371        stack
372            .parameters
373            .insert("Param1".to_string(), "Value1".to_string());
374        stack.description = Some("My stack desc".to_string());
375
376        let xml = describe_stacks_response(&[stack], "req-5");
377        assert!(xml.contains("<Key>env</Key>"));
378        assert!(xml.contains("<Value>prod</Value>"));
379        assert!(xml.contains("<ParameterKey>Param1</ParameterKey>"));
380        assert!(xml.contains("<Description>My stack desc</Description>"));
381    }
382
383    #[test]
384    fn list_stacks_response_lists_summaries() {
385        let stacks = vec![make_stack("s1"), make_stack("s2")];
386        let xml = list_stacks_response(&stacks, "req-6");
387        assert!(xml.contains("<StackName>s1</StackName>"));
388        assert!(xml.contains("<StackName>s2</StackName>"));
389        assert!(xml.contains("ListStacksResponse"));
390    }
391
392    #[test]
393    fn list_stack_resources_response_format() {
394        let resources = vec![
395            make_resource("MyBucket", "AWS::S3::Bucket"),
396            make_resource("MyTable", "AWS::DynamoDB::Table"),
397        ];
398        let xml = list_stack_resources_response(&resources, "req-7");
399        assert!(xml.contains("<LogicalResourceId>MyBucket</LogicalResourceId>"));
400        assert!(xml.contains("<ResourceType>AWS::S3::Bucket</ResourceType>"));
401        assert!(xml.contains("<LogicalResourceId>MyTable</LogicalResourceId>"));
402        assert!(xml.contains("ListStackResourcesResponse"));
403    }
404
405    #[test]
406    fn describe_stack_resources_response_includes_stack_name() {
407        let resources = vec![make_resource("Fn", "AWS::Lambda::Function")];
408        let xml = describe_stack_resources_response(&resources, "my-stack", "req-8");
409        assert!(xml.contains("<StackName>my-stack</StackName>"));
410        assert!(xml.contains("<LogicalResourceId>Fn</LogicalResourceId>"));
411        assert!(xml.contains("DescribeStackResourcesResponse"));
412    }
413
414    #[test]
415    fn get_template_response_contains_body() {
416        let xml = get_template_response(r#"{"AWSTemplateFormatVersion":"2010-09-09"}"#, "req-9");
417        assert!(xml.contains("AWSTemplateFormatVersion"));
418        assert!(xml.contains("GetTemplateResponse"));
419    }
420
421    #[test]
422    fn xml_escaping_works() {
423        let xml = create_stack_response("stack<>\"&'123", "req");
424        assert!(xml.contains("&lt;"));
425        assert!(xml.contains("&gt;"));
426        assert!(xml.contains("&amp;"));
427    }
428
429    #[test]
430    fn describe_stacks_with_notification_arns() {
431        let mut stack = make_stack("notif-stack");
432        stack
433            .notification_arns
434            .push("arn:aws:sns:us-east-1:123456789012:my-topic".to_string());
435        let xml = describe_stacks_response(&[stack], "req-10");
436        assert!(xml.contains("<NotificationARNs>"));
437        assert!(xml.contains("my-topic"));
438    }
439
440    #[test]
441    fn empty_stacks_produces_valid_xml() {
442        let xml = describe_stacks_response(&[], "req-11");
443        assert!(xml.contains("<Stacks>"));
444        assert!(xml.contains("</Stacks>"));
445    }
446}