http_client_vcr/
serializable.rs

1use base64::{engine::general_purpose, Engine as _};
2use http_client::{Error, Request, Response};
3use http_types::{Method, StatusCode, Url};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SerializableRequest {
9    pub method: String,
10    pub url: String,
11    pub headers: HashMap<String, Vec<String>>,
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub body: Option<String>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub body_base64: Option<String>,
16    pub version: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SerializableResponse {
21    pub status: u16,
22    pub headers: HashMap<String, Vec<String>>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub body: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub body_base64: Option<String>,
27    pub version: String,
28}
29
30impl SerializableRequest {
31    pub async fn from_request(mut req: Request) -> Result<Self, Error> {
32        let method = req.method().to_string();
33        let url = req.url().to_string();
34        let version = format!("{:?}", req.version());
35
36        let mut headers = HashMap::new();
37        for (name, values) in req.iter() {
38            let header_values: Vec<String> =
39                values.iter().map(|v| v.as_str().to_string()).collect();
40            headers.insert(name.as_str().to_string(), header_values);
41        }
42
43        let (body, body_base64) = if req.len().is_some() {
44            let body_string = req
45                .body_string()
46                .await
47                .map_err(|e| Error::from_str(500, format!("Failed to read request body: {e}")))?;
48
49            // Check if body contains binary/HTML content that should be base64 encoded
50            if Self::should_base64_encode(&body_string) {
51                (None, Some(general_purpose::STANDARD.encode(&body_string)))
52            } else {
53                (Some(body_string), None)
54            }
55        } else {
56            (None, None)
57        };
58
59        Ok(Self {
60            method,
61            url,
62            headers,
63            body,
64            body_base64,
65            version,
66        })
67    }
68
69    pub async fn to_request(&self) -> Result<Request, Error> {
70        let method: Method = self
71            .method
72            .parse()
73            .map_err(|e| Error::from_str(400, format!("Invalid method: {e}")))?;
74
75        let url: Url = self
76            .url
77            .parse()
78            .map_err(|e| Error::from_str(400, format!("Invalid URL: {e}")))?;
79
80        let mut req = Request::new(method, url);
81
82        for (name, values) in &self.headers {
83            for value in values {
84                let _ = req.append_header(name.as_str(), value.as_str());
85            }
86        }
87
88        if let Some(body) = &self.body {
89            req.set_body(body.clone());
90        } else if let Some(body_base64) = &self.body_base64 {
91            let decoded = general_purpose::STANDARD
92                .decode(body_base64)
93                .map_err(|e| Error::from_str(500, format!("Failed to decode base64 body: {e}")))?;
94            let body_string = String::from_utf8(decoded).map_err(|e| {
95                Error::from_str(
96                    500,
97                    format!("Failed to convert decoded body to string: {e}"),
98                )
99            })?;
100            req.set_body(body_string);
101        }
102
103        Ok(req)
104    }
105
106    /// Determine if content should be base64 encoded to avoid YAML serialization issues
107    fn should_base64_encode(content: &str) -> bool {
108        // Base64 encode if content contains HTML tags, special YAML characters, or high ratio of non-ASCII
109        content.contains('<') && content.contains('>') || // HTML content
110        content.contains('%') && content.len() > 100 || // URL-encoded content
111        content.chars().filter(|c| !c.is_ascii()).count() > content.len() / 10 // High non-ASCII ratio
112    }
113}
114
115impl SerializableResponse {
116    pub async fn from_response(mut res: Response) -> Result<Self, Error> {
117        let status = res.status().into();
118        let version = format!("{:?}", res.version());
119
120        let mut headers = HashMap::new();
121        for (name, values) in res.iter() {
122            let header_values: Vec<String> =
123                values.iter().map(|v| v.as_str().to_string()).collect();
124            headers.insert(name.as_str().to_string(), header_values);
125        }
126
127        let (body, body_base64) = if res.len().is_some() {
128            let body_string = res
129                .body_string()
130                .await
131                .map_err(|e| Error::from_str(500, format!("Failed to read response body: {e}")))?;
132
133            // Check if body contains binary/HTML content that should be base64 encoded
134            if Self::should_base64_encode(&body_string) {
135                (None, Some(general_purpose::STANDARD.encode(&body_string)))
136            } else {
137                (Some(body_string), None)
138            }
139        } else {
140            (None, None)
141        };
142
143        Ok(Self {
144            status,
145            headers,
146            body,
147            body_base64,
148            version,
149        })
150    }
151
152    pub async fn to_response(&self) -> Response {
153        let status = StatusCode::try_from(self.status).unwrap_or(StatusCode::InternalServerError);
154
155        let mut res = Response::new(status);
156
157        for (name, values) in &self.headers {
158            for value in values {
159                let _ = res.append_header(name.as_str(), value.as_str());
160            }
161        }
162
163        if let Some(body) = &self.body {
164            res.set_body(body.clone());
165        } else if let Some(body_base64) = &self.body_base64 {
166            if let Ok(decoded) = general_purpose::STANDARD.decode(body_base64) {
167                if let Ok(body_string) = String::from_utf8(decoded) {
168                    res.set_body(body_string);
169                }
170            }
171        }
172
173        res
174    }
175
176    /// Determine if content should be base64 encoded to avoid YAML serialization issues
177    fn should_base64_encode(content: &str) -> bool {
178        // Base64 encode if content contains HTML tags, special YAML characters, or high ratio of non-ASCII
179        content.contains('<') && content.contains('>') || // HTML content
180        content.contains('%') && content.len() > 100 || // URL-encoded content
181        content.chars().filter(|c| !c.is_ascii()).count() > content.len() / 10 // High non-ASCII ratio
182    }
183}