Skip to main content

harn_vm/http/
mock.rs

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