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