1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::BTreeMap;
4use std::sync::Mutex;
5
6use crate::value::VmValue;
7
8#[derive(Clone, Debug)]
9pub(super) struct MockResponse {
10 pub(super) status: i64,
11 pub(super) body: String,
12 pub(super) headers: crate::value::DictMap,
13}
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct HttpMockResponse {
17 pub status: i64,
18 pub body: String,
19 pub headers: BTreeMap<String, String>,
20}
21
22impl HttpMockResponse {
23 pub fn new(status: i64, body: impl Into<String>) -> Self {
24 Self {
25 status,
26 body: body.into(),
27 headers: BTreeMap::new(),
28 }
29 }
30
31 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
32 self.headers.insert(name.into(), value.into());
33 self
34 }
35}
36
37impl From<HttpMockResponse> for MockResponse {
38 fn from(value: HttpMockResponse) -> Self {
39 Self {
40 status: value.status,
41 body: value.body,
42 headers: value
43 .headers
44 .into_iter()
45 .map(|(key, value)| {
46 (
47 crate::value::intern_key(&key),
48 VmValue::String(arcstr::ArcStr::from(value)),
49 )
50 })
51 .collect(),
52 }
53 }
54}
55
56#[derive(Debug)]
57struct HttpMock {
58 method: String,
59 url_pattern: String,
60 responses: Vec<MockResponse>,
61 next_response: usize,
62}
63
64#[derive(Clone, Debug)]
65struct HttpMockCall {
66 method: String,
67 url: String,
68 headers: crate::value::DictMap,
69 body: Option<String>,
70}
71
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct HttpMockCallSnapshot {
74 pub method: String,
75 pub url: String,
76 pub headers: BTreeMap<String, String>,
77 pub body: Option<String>,
78}
79
80thread_local! {
81 static HTTP_MOCKS: RefCell<Vec<HttpMock>> = const { RefCell::new(Vec::new()) };
82 static HTTP_MOCK_CALLS: RefCell<Vec<HttpMockCall>> = const { RefCell::new(Vec::new()) };
83}
84
85#[derive(Debug, Default)]
91pub(crate) struct HttpMockRegistry {
92 inner: Mutex<HttpMockRegistryInner>,
93}
94
95#[derive(Debug, Default)]
96struct HttpMockRegistryInner {
97 mocks: Vec<HttpMock>,
98 calls: Vec<HttpMockCall>,
99}
100
101impl HttpMockRegistry {
102 pub(crate) fn clear(&self) {
103 let mut inner = self.inner.lock().expect("harness HTTP mocks poisoned");
104 inner.mocks.clear();
105 inner.calls.clear();
106 }
107
108 pub(crate) fn has_match(&self, method: &str, url: &str) -> bool {
109 self.inner
110 .lock()
111 .expect("harness HTTP mocks poisoned")
112 .mocks
113 .iter()
114 .any(|mock| {
115 !mock.responses.is_empty()
116 && (mock.method == "*" || mock.method.eq_ignore_ascii_case(method))
117 && url_matches(&mock.url_pattern, url)
118 })
119 }
120
121 pub(super) fn register(
122 &self,
123 method: impl Into<String>,
124 url_pattern: impl Into<String>,
125 responses: Vec<MockResponse>,
126 ) {
127 let method = method.into();
128 let url_pattern = url_pattern.into();
129 let mut inner = self.inner.lock().expect("harness HTTP mocks poisoned");
130 inner
131 .mocks
132 .retain(|mock| !(mock.method == method && mock.url_pattern == url_pattern));
133 inner.mocks.push(HttpMock {
134 method,
135 url_pattern,
136 responses,
137 next_response: 0,
138 });
139 }
140
141 pub(super) fn consume(
142 &self,
143 method: &str,
144 url: &str,
145 headers: crate::value::DictMap,
146 body: Option<String>,
147 ) -> Option<MockResponse> {
148 let mut inner = self.inner.lock().expect("harness HTTP mocks poisoned");
149 let response = inner.mocks.iter_mut().find_map(|mock| {
150 if (mock.method == "*" || mock.method.eq_ignore_ascii_case(method))
151 && url_matches(&mock.url_pattern, url)
152 {
153 let last_index = mock.responses.len().checked_sub(1)?;
154 let index = mock.next_response.min(last_index);
155 let response = mock.responses[index].clone();
156 if mock.next_response < last_index {
157 mock.next_response += 1;
158 }
159 Some(response)
160 } else {
161 None
162 }
163 })?;
164 inner.calls.push(HttpMockCall {
165 method: method.to_string(),
166 url: url.to_string(),
167 headers,
168 body,
169 });
170 Some(response)
171 }
172
173 pub(crate) fn calls_value(&self, redact_sensitive: bool) -> Vec<VmValue> {
174 let inner = self.inner.lock().expect("harness HTTP mocks poisoned");
175 http_mock_calls_from(&inner.calls, redact_sensitive)
176 }
177}
178
179pub(super) fn reset_http_mocks() {
180 HTTP_MOCKS.with(|mocks| mocks.borrow_mut().clear());
181 HTTP_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
182}
183
184pub(super) fn clear_http_mocks() {
185 reset_http_mocks();
186}
187
188pub fn push_http_mock(
189 method: impl Into<String>,
190 url_pattern: impl Into<String>,
191 responses: Vec<HttpMockResponse>,
192) {
193 let responses = if responses.is_empty() {
194 vec![MockResponse::from(HttpMockResponse::new(200, ""))]
195 } else {
196 responses.into_iter().map(MockResponse::from).collect()
197 };
198 register_http_mock(method.into(), url_pattern.into(), responses);
199}
200
201pub(super) fn register_http_mock(
202 method: impl Into<String>,
203 url_pattern: impl Into<String>,
204 responses: Vec<MockResponse>,
205) {
206 let method = method.into();
207 let url_pattern = url_pattern.into();
208 HTTP_MOCKS.with(|mocks| {
209 let mut mocks = mocks.borrow_mut();
210 mocks.retain(|mock| !(mock.method == method && mock.url_pattern == url_pattern));
215 mocks.push(HttpMock {
216 method,
217 url_pattern,
218 responses,
219 next_response: 0,
220 });
221 });
222}
223
224pub fn http_mock_calls_snapshot() -> Vec<HttpMockCallSnapshot> {
225 HTTP_MOCK_CALLS.with(|calls| {
226 calls
227 .borrow()
228 .iter()
229 .map(|call| HttpMockCallSnapshot {
230 method: call.method.clone(),
231 url: call.url.clone(),
232 headers: call
233 .headers
234 .iter()
235 .map(|(key, value)| (key.to_string(), value.display()))
236 .collect(),
237 body: call.body.clone(),
238 })
239 .collect()
240 })
241}
242
243pub(super) fn http_mock_calls_value(redact_sensitive: bool) -> Vec<VmValue> {
244 HTTP_MOCK_CALLS.with(|calls| http_mock_calls_from(&calls.borrow(), redact_sensitive))
245}
246
247fn http_mock_calls_from(calls: &[HttpMockCall], redact_sensitive: bool) -> Vec<VmValue> {
248 calls
249 .iter()
250 .map(|call| {
251 let mut dict = BTreeMap::new();
252 dict.put_str("method", call.method.as_str());
253 dict.put_str("url", redact_mock_call_url(&call.url, redact_sensitive));
254 dict.insert(
255 "headers".to_string(),
256 VmValue::dict(mock_call_headers_value(&call.headers, redact_sensitive)),
257 );
258 dict.insert(
259 "body".to_string(),
260 match &call.body {
261 Some(body) => VmValue::String(arcstr::ArcStr::from(body.as_str())),
262 None => VmValue::Nil,
263 },
264 );
265 VmValue::dict(dict)
266 })
267 .collect()
268}
269
270pub(super) fn parse_mock_responses(response: &crate::value::DictMap) -> Vec<MockResponse> {
271 let scripted = response
272 .get("responses")
273 .and_then(|value| match value {
274 VmValue::List(items) => Some(
275 items
276 .iter()
277 .filter_map(|item| item.as_dict().map(parse_mock_response_dict))
278 .collect::<Vec<_>>(),
279 ),
280 _ => None,
281 })
282 .unwrap_or_default();
283
284 if scripted.is_empty() {
285 vec![parse_mock_response_dict(response)]
286 } else {
287 scripted
288 }
289}
290
291fn parse_mock_response_dict(response: &crate::value::DictMap) -> MockResponse {
292 let status = response
293 .get("status")
294 .and_then(|v| v.as_int())
295 .unwrap_or(200);
296 let body = response
297 .get("body")
298 .map(|v| v.display())
299 .unwrap_or_default();
300 let headers = response
301 .get("headers")
302 .and_then(|v| v.as_dict())
303 .cloned()
304 .unwrap_or_default();
305 MockResponse {
306 status,
307 body,
308 headers,
309 }
310}
311
312pub(super) fn consume_http_mock(
313 method: &str,
314 url: &str,
315 headers: crate::value::DictMap,
316 body: Option<String>,
317) -> Option<MockResponse> {
318 let response = HTTP_MOCKS.with(|mocks| {
319 let mut mocks = mocks.borrow_mut();
320 for mock in mocks.iter_mut() {
321 if (mock.method == "*" || mock.method.eq_ignore_ascii_case(method))
322 && url_matches(&mock.url_pattern, url)
323 {
324 let Some(last_index) = mock.responses.len().checked_sub(1) else {
325 continue;
326 };
327 let index = mock.next_response.min(last_index);
328 let response = mock.responses[index].clone();
329 if mock.next_response < last_index {
330 mock.next_response += 1;
331 }
332 return Some(response);
333 }
334 }
335 None
336 })?;
337
338 HTTP_MOCK_CALLS.with(|calls| {
339 calls.borrow_mut().push(HttpMockCall {
340 method: method.to_string(),
341 url: url.to_string(),
342 headers,
343 body,
344 });
345 });
346
347 Some(response)
348}
349
350pub(super) fn url_matches(pattern: &str, url: &str) -> bool {
352 if pattern == "*" {
353 return true;
354 }
355 if !pattern.contains('*') {
356 return pattern == url;
357 }
358 let parts: Vec<&str> = pattern.split('*').collect();
360 let mut remaining = url;
361 for (i, part) in parts.iter().enumerate() {
362 if part.is_empty() {
363 continue;
364 }
365 if i == 0 {
366 match remaining.strip_prefix(*part) {
367 Some(rest) => remaining = rest,
368 None => return false,
369 }
370 } else if i == parts.len() - 1 {
371 if !remaining.ends_with(part) {
372 return false;
373 }
374 remaining = "";
375 } else {
376 match remaining.split_once(*part) {
377 Some((_, rest)) => remaining = rest,
378 None => return false,
379 }
380 }
381 }
382 true
383}
384
385pub(super) fn redact_mock_call_url(url: &str, redact: bool) -> String {
386 if !redact {
387 return url.to_string();
388 }
389 crate::redact::current_policy().redact_url(url)
390}
391
392pub(super) fn mock_call_headers_value(
393 headers: &crate::value::DictMap,
394 redact_headers: bool,
395) -> crate::value::DictMap {
396 if !redact_headers {
397 return headers.clone();
398 }
399 let policy = crate::redact::current_policy();
400 headers
401 .iter()
402 .map(|(key, value)| {
403 let value = if policy.header_is_sensitive(key) {
404 VmValue::String(arcstr::ArcStr::from(crate::redact::REDACTED_PLACEHOLDER))
405 } else {
406 value.clone()
407 };
408 (key.clone(), value)
409 })
410 .collect()
411}