Skip to main content

harn_vm/http/
mock.rs

1use std::cell::RefCell;
2use std::collections::BTreeMap;
3
4use crate::value::VmValue;
5
6#[derive(Clone)]
7pub(super) struct MockResponse {
8    pub(super) status: i64,
9    pub(super) body: String,
10    pub(super) headers: BTreeMap<String, VmValue>,
11}
12
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct HttpMockResponse {
15    pub status: i64,
16    pub body: String,
17    pub headers: BTreeMap<String, String>,
18}
19
20impl HttpMockResponse {
21    pub fn new(status: i64, body: impl Into<String>) -> Self {
22        Self {
23            status,
24            body: body.into(),
25            headers: BTreeMap::new(),
26        }
27    }
28
29    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
30        self.headers.insert(name.into(), value.into());
31        self
32    }
33}
34
35impl From<HttpMockResponse> for MockResponse {
36    fn from(value: HttpMockResponse) -> Self {
37        Self {
38            status: value.status,
39            body: value.body,
40            headers: value
41                .headers
42                .into_iter()
43                .map(|(key, value)| (key, VmValue::String(std::sync::Arc::from(value))))
44                .collect(),
45        }
46    }
47}
48
49struct HttpMock {
50    method: String,
51    url_pattern: String,
52    responses: Vec<MockResponse>,
53    next_response: usize,
54}
55
56#[derive(Clone)]
57struct HttpMockCall {
58    method: String,
59    url: String,
60    headers: BTreeMap<String, VmValue>,
61    body: Option<String>,
62}
63
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct HttpMockCallSnapshot {
66    pub method: String,
67    pub url: String,
68    pub headers: BTreeMap<String, String>,
69    pub body: Option<String>,
70}
71
72thread_local! {
73    static HTTP_MOCKS: RefCell<Vec<HttpMock>> = const { RefCell::new(Vec::new()) };
74    static HTTP_MOCK_CALLS: RefCell<Vec<HttpMockCall>> = const { RefCell::new(Vec::new()) };
75}
76
77pub(super) fn reset_http_mocks() {
78    HTTP_MOCKS.with(|mocks| mocks.borrow_mut().clear());
79    HTTP_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
80}
81
82pub(super) fn clear_http_mocks() {
83    reset_http_mocks();
84}
85
86pub fn push_http_mock(
87    method: impl Into<String>,
88    url_pattern: impl Into<String>,
89    responses: Vec<HttpMockResponse>,
90) {
91    let responses = if responses.is_empty() {
92        vec![MockResponse::from(HttpMockResponse::new(200, ""))]
93    } else {
94        responses.into_iter().map(MockResponse::from).collect()
95    };
96    register_http_mock(method.into(), url_pattern.into(), responses);
97}
98
99pub(super) fn register_http_mock(
100    method: impl Into<String>,
101    url_pattern: impl Into<String>,
102    responses: Vec<MockResponse>,
103) {
104    let method = method.into();
105    let url_pattern = url_pattern.into();
106    HTTP_MOCKS.with(|mocks| {
107        let mut mocks = mocks.borrow_mut();
108        // Re-registering the same (method, url_pattern) replaces the prior
109        // mock so tests can override per-case responses without first calling
110        // http_mock_clear(). Without this, the original mock keeps matching
111        // forever and the new one is dead.
112        mocks.retain(|mock| !(mock.method == method && mock.url_pattern == url_pattern));
113        mocks.push(HttpMock {
114            method,
115            url_pattern,
116            responses,
117            next_response: 0,
118        });
119    });
120}
121
122pub fn http_mock_calls_snapshot() -> Vec<HttpMockCallSnapshot> {
123    HTTP_MOCK_CALLS.with(|calls| {
124        calls
125            .borrow()
126            .iter()
127            .map(|call| HttpMockCallSnapshot {
128                method: call.method.clone(),
129                url: call.url.clone(),
130                headers: call
131                    .headers
132                    .iter()
133                    .map(|(key, value)| (key.clone(), value.display()))
134                    .collect(),
135                body: call.body.clone(),
136            })
137            .collect()
138    })
139}
140
141pub(super) fn http_mock_calls_value(redact_sensitive: bool) -> Vec<VmValue> {
142    HTTP_MOCK_CALLS.with(|calls| {
143        calls
144            .borrow()
145            .iter()
146            .map(|call| {
147                let mut dict = BTreeMap::new();
148                dict.insert(
149                    "method".to_string(),
150                    VmValue::String(std::sync::Arc::from(call.method.as_str())),
151                );
152                dict.insert(
153                    "url".to_string(),
154                    VmValue::String(std::sync::Arc::from(redact_mock_call_url(
155                        &call.url,
156                        redact_sensitive,
157                    ))),
158                );
159                dict.insert(
160                    "headers".to_string(),
161                    VmValue::Dict(std::sync::Arc::new(mock_call_headers_value(
162                        &call.headers,
163                        redact_sensitive,
164                    ))),
165                );
166                dict.insert(
167                    "body".to_string(),
168                    match &call.body {
169                        Some(body) => VmValue::String(std::sync::Arc::from(body.as_str())),
170                        None => VmValue::Nil,
171                    },
172                );
173                VmValue::Dict(std::sync::Arc::new(dict))
174            })
175            .collect()
176    })
177}
178
179pub(super) fn parse_mock_responses(response: &BTreeMap<String, VmValue>) -> Vec<MockResponse> {
180    let scripted = response
181        .get("responses")
182        .and_then(|value| match value {
183            VmValue::List(items) => Some(
184                items
185                    .iter()
186                    .filter_map(|item| item.as_dict().map(parse_mock_response_dict))
187                    .collect::<Vec<_>>(),
188            ),
189            _ => None,
190        })
191        .unwrap_or_default();
192
193    if scripted.is_empty() {
194        vec![parse_mock_response_dict(response)]
195    } else {
196        scripted
197    }
198}
199
200fn parse_mock_response_dict(response: &BTreeMap<String, VmValue>) -> MockResponse {
201    let status = response
202        .get("status")
203        .and_then(|v| v.as_int())
204        .unwrap_or(200);
205    let body = response
206        .get("body")
207        .map(|v| v.display())
208        .unwrap_or_default();
209    let headers = response
210        .get("headers")
211        .and_then(|v| v.as_dict())
212        .cloned()
213        .unwrap_or_default();
214    MockResponse {
215        status,
216        body,
217        headers,
218    }
219}
220
221pub(super) fn consume_http_mock(
222    method: &str,
223    url: &str,
224    headers: BTreeMap<String, VmValue>,
225    body: Option<String>,
226) -> Option<MockResponse> {
227    let response = HTTP_MOCKS.with(|mocks| {
228        let mut mocks = mocks.borrow_mut();
229        for mock in mocks.iter_mut() {
230            if (mock.method == "*" || mock.method.eq_ignore_ascii_case(method))
231                && url_matches(&mock.url_pattern, url)
232            {
233                let Some(last_index) = mock.responses.len().checked_sub(1) else {
234                    continue;
235                };
236                let index = mock.next_response.min(last_index);
237                let response = mock.responses[index].clone();
238                if mock.next_response < last_index {
239                    mock.next_response += 1;
240                }
241                return Some(response);
242            }
243        }
244        None
245    })?;
246
247    HTTP_MOCK_CALLS.with(|calls| {
248        calls.borrow_mut().push(HttpMockCall {
249            method: method.to_string(),
250            url: url.to_string(),
251            headers,
252            body,
253        });
254    });
255
256    Some(response)
257}
258
259/// Check if a URL matches a mock pattern (exact or glob with `*`).
260pub(super) fn url_matches(pattern: &str, url: &str) -> bool {
261    if pattern == "*" {
262        return true;
263    }
264    if !pattern.contains('*') {
265        return pattern == url;
266    }
267    // Multi-glob: split on `*` and match segments in order.
268    let parts: Vec<&str> = pattern.split('*').collect();
269    let mut remaining = url;
270    for (i, part) in parts.iter().enumerate() {
271        if part.is_empty() {
272            continue;
273        }
274        if i == 0 {
275            if !remaining.starts_with(part) {
276                return false;
277            }
278            remaining = &remaining[part.len()..];
279        } else if i == parts.len() - 1 {
280            if !remaining.ends_with(part) {
281                return false;
282            }
283            remaining = "";
284        } else {
285            match remaining.find(part) {
286                Some(pos) => remaining = &remaining[pos + part.len()..],
287                None => return false,
288            }
289        }
290    }
291    true
292}
293
294pub(super) fn redact_mock_call_url(url: &str, redact: bool) -> String {
295    if !redact {
296        return url.to_string();
297    }
298    crate::redact::current_policy().redact_url(url)
299}
300
301pub(super) fn mock_call_headers_value(
302    headers: &BTreeMap<String, VmValue>,
303    redact_headers: bool,
304) -> BTreeMap<String, VmValue> {
305    if !redact_headers {
306        return headers.clone();
307    }
308    let policy = crate::redact::current_policy();
309    headers
310        .iter()
311        .map(|(key, value)| {
312            let value = if policy.header_is_sensitive(key) {
313                VmValue::String(std::sync::Arc::from(crate::redact::REDACTED_PLACEHOLDER))
314            } else {
315                value.clone()
316            };
317            (key.clone(), value)
318        })
319        .collect()
320}