Skip to main content

rustack_ses_http/
response.rs

1//! SES XML response formatting and error serialization.
2//!
3//! SES v1 responses use `text/xml` content type following the awsQuery protocol.
4//! SES v2 responses use `application/json`.
5//!
6//! All SES v1 responses follow the pattern:
7//!
8//! ```xml
9//! <{Operation}Response xmlns="http://ses.amazonaws.com/doc/2010-12-01/">
10//!   <{Operation}Result>
11//!     ...fields...
12//!   </{Operation}Result>
13//!   <ResponseMetadata>
14//!     <RequestId>{uuid}</RequestId>
15//!   </ResponseMetadata>
16//! </{Operation}Response>
17//! ```
18
19use rustack_ses_model::error::SesError;
20
21use crate::body::SesResponseBody;
22
23/// Content type for SES v1 XML responses.
24pub const XML_CONTENT_TYPE: &str = "text/xml";
25
26/// Content type for SES v2 JSON responses.
27pub const JSON_CONTENT_TYPE: &str = "application/json";
28
29/// The SES v1 XML namespace.
30const XML_NS: &str = "http://ses.amazonaws.com/doc/2010-12-01/";
31
32/// Build a success XML response with the given body and request ID.
33#[must_use]
34pub fn xml_response(xml: String, request_id: &str) -> http::Response<SesResponseBody> {
35    let body = SesResponseBody::from_xml(xml.into_bytes());
36    http::Response::builder()
37        .status(http::StatusCode::OK)
38        .header("content-type", XML_CONTENT_TYPE)
39        .header("x-amzn-requestid", request_id)
40        .body(body)
41        .expect("valid XML response")
42}
43
44/// Build a success JSON response for SES v2.
45#[must_use]
46pub fn json_response(json: String, status: http::StatusCode) -> http::Response<SesResponseBody> {
47    let body = SesResponseBody::from_json(json);
48    http::Response::builder()
49        .status(status)
50        .header("content-type", JSON_CONTENT_TYPE)
51        .body(body)
52        .expect("valid JSON response")
53}
54
55/// Serialize an SES error into an XML error response body string.
56#[must_use]
57pub fn error_to_xml(error: &SesError, request_id: &str) -> String {
58    format!(
59        "<ErrorResponse \
60         xmlns=\"{XML_NS}\"><Error><Type>{}</Type><Code>{}</Code><Message>{}</Message></\
61         Error><RequestId>{}</RequestId></ErrorResponse>",
62        error.code.fault(),
63        error.code.code(),
64        xml_escape(&error.message),
65        xml_escape(request_id),
66    )
67}
68
69/// Convert an `SesError` into a complete HTTP error response (XML for v1).
70#[must_use]
71pub fn error_to_response(error: &SesError, request_id: &str) -> http::Response<SesResponseBody> {
72    let xml = error_to_xml(error, request_id);
73    let body = SesResponseBody::from_xml(xml.into_bytes());
74    http::Response::builder()
75        .status(error.status_code)
76        .header("content-type", XML_CONTENT_TYPE)
77        .header("x-amzn-requestid", request_id)
78        .body(body)
79        .expect("valid error response")
80}
81
82/// Convert an `SesError` into a JSON error response (for v2).
83#[must_use]
84pub fn error_to_json_response(error: &SesError) -> http::Response<SesResponseBody> {
85    let json = serde_json::json!({
86        "__type": error.code.code(),
87        "message": error.message,
88    });
89    let body = SesResponseBody::from_json(json.to_string());
90    http::Response::builder()
91        .status(error.status_code)
92        .header("content-type", JSON_CONTENT_TYPE)
93        .body(body)
94        .expect("valid JSON error response")
95}
96
97/// XML-escape a string value.
98///
99/// Replaces the five XML special characters with their entity references.
100#[must_use]
101pub fn xml_escape(s: &str) -> String {
102    if !s.contains(['&', '<', '>', '"', '\'']) {
103        return s.to_owned();
104    }
105
106    let mut result = String::with_capacity(s.len() + 16);
107    for ch in s.chars() {
108        match ch {
109            '&' => result.push_str("&amp;"),
110            '<' => result.push_str("&lt;"),
111            '>' => result.push_str("&gt;"),
112            '"' => result.push_str("&quot;"),
113            '\'' => result.push_str("&apos;"),
114            _ => result.push(ch),
115        }
116    }
117    result
118}
119
120/// Simple XML writer for building SES response XML.
121#[derive(Debug)]
122pub struct XmlWriter {
123    buf: String,
124}
125
126impl Default for XmlWriter {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132impl XmlWriter {
133    /// Create a new `XmlWriter`.
134    #[must_use]
135    pub fn new() -> Self {
136        Self {
137            buf: String::with_capacity(512),
138        }
139    }
140
141    /// Start the response envelope: `<{op}Response xmlns="...">`.
142    pub fn start_response(&mut self, operation: &str) {
143        self.buf.push('<');
144        self.buf.push_str(operation);
145        self.buf.push_str("Response xmlns=\"");
146        self.buf.push_str(XML_NS);
147        self.buf.push_str("\">");
148    }
149
150    /// Start the result element: `<{op}Result>`.
151    pub fn start_result(&mut self, operation: &str) {
152        self.buf.push('<');
153        self.buf.push_str(operation);
154        self.buf.push_str("Result>");
155    }
156
157    /// End an element: `</{name}>`.
158    pub fn end_element(&mut self, name: &str) {
159        self.buf.push_str("</");
160        self.buf.push_str(name);
161        self.buf.push('>');
162    }
163
164    /// Write a simple text element: `<Name>Value</Name>`.
165    pub fn write_element(&mut self, name: &str, value: &str) {
166        self.buf.push('<');
167        self.buf.push_str(name);
168        self.buf.push('>');
169        self.buf.push_str(&xml_escape(value));
170        self.buf.push_str("</");
171        self.buf.push_str(name);
172        self.buf.push('>');
173    }
174
175    /// Write an optional element (skip if `None`).
176    pub fn write_optional_element(&mut self, name: &str, value: Option<&str>) {
177        if let Some(v) = value {
178            self.write_element(name, v);
179        }
180    }
181
182    /// Write a boolean element: `<name>true</name>` or `<name>false</name>`.
183    pub fn write_bool_element(&mut self, name: &str, value: bool) {
184        self.write_element(name, if value { "true" } else { "false" });
185    }
186
187    /// Write a float element.
188    pub fn write_f64_element(&mut self, name: &str, value: f64) {
189        self.write_element(name, &value.to_string());
190    }
191
192    /// Write an i64 element.
193    pub fn write_i64_element(&mut self, name: &str, value: i64) {
194        self.write_element(name, &value.to_string());
195    }
196
197    /// Write the `<ResponseMetadata>` block with a `<RequestId>`.
198    pub fn write_response_metadata(&mut self, request_id: &str) {
199        self.buf.push_str("<ResponseMetadata>");
200        self.write_element("RequestId", request_id);
201        self.buf.push_str("</ResponseMetadata>");
202    }
203
204    /// Write raw XML content without escaping.
205    pub fn raw(&mut self, s: &str) {
206        self.buf.push_str(s);
207    }
208
209    /// Consume the writer and return the final XML string.
210    #[must_use]
211    pub fn into_string(self) -> String {
212        self.buf
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn test_should_escape_xml_special_chars() {
222        assert_eq!(xml_escape("hello"), "hello");
223        assert_eq!(xml_escape("a & b"), "a &amp; b");
224        assert_eq!(xml_escape("<tag>"), "&lt;tag&gt;");
225    }
226
227    #[test]
228    fn test_should_format_error_xml() {
229        let err = SesError::message_rejected("Email address is not verified.");
230        let xml = error_to_xml(&err, "req-123");
231        assert!(xml.contains("<Code>MessageRejected</Code>"));
232        assert!(xml.contains("Email address is not verified."));
233        assert!(xml.contains("<RequestId>req-123</RequestId>"));
234    }
235
236    #[test]
237    fn test_should_build_error_response_with_correct_status() {
238        let err = SesError::template_does_not_exist("my-template");
239        let resp = error_to_response(&err, "test-req");
240        assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
241    }
242
243    #[test]
244    fn test_should_build_xml_with_writer() {
245        let mut w = XmlWriter::new();
246        w.start_response("SendEmail");
247        w.start_result("SendEmail");
248        w.write_element("MessageId", "test-id-123");
249        w.end_element("SendEmailResult");
250        w.write_response_metadata("req-789");
251        w.end_element("SendEmailResponse");
252        let xml = w.into_string();
253        assert!(xml.contains("<MessageId>test-id-123</MessageId>"));
254        assert!(xml.contains("<RequestId>req-789</RequestId>"));
255        assert!(xml.contains("xmlns=\"http://ses.amazonaws.com/doc/2010-12-01/\""));
256    }
257
258    #[test]
259    fn test_should_build_json_error_response() {
260        let err = SesError::internal_error("Something went wrong");
261        let resp = error_to_json_response(&err);
262        assert_eq!(resp.status(), http::StatusCode::INTERNAL_SERVER_ERROR);
263        assert_eq!(
264            resp.headers().get("content-type").unwrap(),
265            JSON_CONTENT_TYPE,
266        );
267    }
268
269    #[test]
270    fn test_should_build_success_json_response() {
271        let resp = json_response("{\"ok\":true}".to_owned(), http::StatusCode::OK);
272        assert_eq!(resp.status(), http::StatusCode::OK);
273        assert_eq!(
274            resp.headers().get("content-type").unwrap(),
275            JSON_CONTENT_TYPE,
276        );
277    }
278}