use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
use crate::value::VmValue;
#[derive(Clone)]
pub(super) struct MockResponse {
pub(super) status: i64,
pub(super) body: String,
pub(super) headers: BTreeMap<String, VmValue>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HttpMockResponse {
pub status: i64,
pub body: String,
pub headers: BTreeMap<String, String>,
}
impl HttpMockResponse {
pub fn new(status: i64, body: impl Into<String>) -> Self {
Self {
status,
body: body.into(),
headers: BTreeMap::new(),
}
}
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
}
impl From<HttpMockResponse> for MockResponse {
fn from(value: HttpMockResponse) -> Self {
Self {
status: value.status,
body: value.body,
headers: value
.headers
.into_iter()
.map(|(key, value)| (key, VmValue::String(Rc::from(value))))
.collect(),
}
}
}
struct HttpMock {
method: String,
url_pattern: String,
responses: Vec<MockResponse>,
next_response: usize,
}
#[derive(Clone)]
struct HttpMockCall {
method: String,
url: String,
headers: BTreeMap<String, VmValue>,
body: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HttpMockCallSnapshot {
pub method: String,
pub url: String,
pub headers: BTreeMap<String, String>,
pub body: Option<String>,
}
thread_local! {
static HTTP_MOCKS: RefCell<Vec<HttpMock>> = const { RefCell::new(Vec::new()) };
static HTTP_MOCK_CALLS: RefCell<Vec<HttpMockCall>> = const { RefCell::new(Vec::new()) };
}
pub(super) fn reset_http_mocks() {
HTTP_MOCKS.with(|mocks| mocks.borrow_mut().clear());
HTTP_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
}
pub(super) fn clear_http_mocks() {
reset_http_mocks();
}
pub fn push_http_mock(
method: impl Into<String>,
url_pattern: impl Into<String>,
responses: Vec<HttpMockResponse>,
) {
let responses = if responses.is_empty() {
vec![MockResponse::from(HttpMockResponse::new(200, ""))]
} else {
responses.into_iter().map(MockResponse::from).collect()
};
register_http_mock(method.into(), url_pattern.into(), responses);
}
pub(super) fn register_http_mock(
method: impl Into<String>,
url_pattern: impl Into<String>,
responses: Vec<MockResponse>,
) {
let method = method.into();
let url_pattern = url_pattern.into();
HTTP_MOCKS.with(|mocks| {
let mut mocks = mocks.borrow_mut();
mocks.retain(|mock| !(mock.method == method && mock.url_pattern == url_pattern));
mocks.push(HttpMock {
method,
url_pattern,
responses,
next_response: 0,
});
});
}
pub fn http_mock_calls_snapshot() -> Vec<HttpMockCallSnapshot> {
HTTP_MOCK_CALLS.with(|calls| {
calls
.borrow()
.iter()
.map(|call| HttpMockCallSnapshot {
method: call.method.clone(),
url: call.url.clone(),
headers: call
.headers
.iter()
.map(|(key, value)| (key.clone(), value.display()))
.collect(),
body: call.body.clone(),
})
.collect()
})
}
pub(super) fn http_mock_calls_value(redact_sensitive: bool) -> Vec<VmValue> {
HTTP_MOCK_CALLS.with(|calls| {
calls
.borrow()
.iter()
.map(|call| {
let mut dict = BTreeMap::new();
dict.insert(
"method".to_string(),
VmValue::String(Rc::from(call.method.as_str())),
);
dict.insert(
"url".to_string(),
VmValue::String(Rc::from(redact_mock_call_url(&call.url, redact_sensitive))),
);
dict.insert(
"headers".to_string(),
VmValue::Dict(Rc::new(mock_call_headers_value(
&call.headers,
redact_sensitive,
))),
);
dict.insert(
"body".to_string(),
match &call.body {
Some(body) => VmValue::String(Rc::from(body.as_str())),
None => VmValue::Nil,
},
);
VmValue::Dict(Rc::new(dict))
})
.collect()
})
}
pub(super) fn parse_mock_responses(response: &BTreeMap<String, VmValue>) -> Vec<MockResponse> {
let scripted = response
.get("responses")
.and_then(|value| match value {
VmValue::List(items) => Some(
items
.iter()
.filter_map(|item| item.as_dict().map(parse_mock_response_dict))
.collect::<Vec<_>>(),
),
_ => None,
})
.unwrap_or_default();
if scripted.is_empty() {
vec![parse_mock_response_dict(response)]
} else {
scripted
}
}
fn parse_mock_response_dict(response: &BTreeMap<String, VmValue>) -> MockResponse {
let status = response
.get("status")
.and_then(|v| v.as_int())
.unwrap_or(200);
let body = response
.get("body")
.map(|v| v.display())
.unwrap_or_default();
let headers = response
.get("headers")
.and_then(|v| v.as_dict())
.cloned()
.unwrap_or_default();
MockResponse {
status,
body,
headers,
}
}
pub(super) fn consume_http_mock(
method: &str,
url: &str,
headers: BTreeMap<String, VmValue>,
body: Option<String>,
) -> Option<MockResponse> {
let response = HTTP_MOCKS.with(|mocks| {
let mut mocks = mocks.borrow_mut();
for mock in mocks.iter_mut() {
if (mock.method == "*" || mock.method.eq_ignore_ascii_case(method))
&& url_matches(&mock.url_pattern, url)
{
let Some(last_index) = mock.responses.len().checked_sub(1) else {
continue;
};
let index = mock.next_response.min(last_index);
let response = mock.responses[index].clone();
if mock.next_response < last_index {
mock.next_response += 1;
}
return Some(response);
}
}
None
})?;
HTTP_MOCK_CALLS.with(|calls| {
calls.borrow_mut().push(HttpMockCall {
method: method.to_string(),
url: url.to_string(),
headers,
body,
});
});
Some(response)
}
pub(super) fn url_matches(pattern: &str, url: &str) -> bool {
if pattern == "*" {
return true;
}
if !pattern.contains('*') {
return pattern == url;
}
let parts: Vec<&str> = pattern.split('*').collect();
let mut remaining = url;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !remaining.starts_with(part) {
return false;
}
remaining = &remaining[part.len()..];
} else if i == parts.len() - 1 {
if !remaining.ends_with(part) {
return false;
}
remaining = "";
} else {
match remaining.find(part) {
Some(pos) => remaining = &remaining[pos + part.len()..],
None => return false,
}
}
}
true
}
pub(super) fn redact_mock_call_url(url: &str, redact: bool) -> String {
if !redact {
return url.to_string();
}
crate::redact::current_policy().redact_url(url)
}
pub(super) fn mock_call_headers_value(
headers: &BTreeMap<String, VmValue>,
redact_headers: bool,
) -> BTreeMap<String, VmValue> {
if !redact_headers {
return headers.clone();
}
let policy = crate::redact::current_policy();
headers
.iter()
.map(|(key, value)| {
let value = if policy.header_is_sensitive(key) {
VmValue::String(Rc::from(crate::redact::REDACTED_PLACEHOLDER))
} else {
value.clone()
};
(key.clone(), value)
})
.collect()
}