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 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
253pub(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 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}