const MAX_RECORDED_REQUESTS: usize = 10_000;
use super::predicates::stub_matches;
use super::response::{
create_response_preview, create_stub_from_proxy_response, execute_stub_response_with_rift,
get_rift_script_config,
};
use super::types::{
DebugImposter, DebugResponsePreview, DebugStubInfo, ImposterConfig, ProxyResponse,
RecordedRequest, ResponseMode, RiftResponseExtension, RiftScriptConfig, Stub, StubResponse,
};
use crate::backends::InMemoryFlowStore;
use crate::behaviors::{HasRepeatBehavior, RuleCycler};
use crate::extensions::flow_state::{FlowStore, NoOpFlowStore};
use crate::recording::{ProxyMode, RecordedResponse, RecordingStore, RequestSignature};
use anyhow::Context;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::broadcast;
use tracing::{debug, error, info, warn};
const MAX_PROXY_RESPONSE_BODY_SIZE: usize = 10 * 1024 * 1024;
static HTTP_CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
fn get_http_client() -> &'static reqwest::Client {
HTTP_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.pool_max_idle_per_host(0) .build()
.expect("Failed to create HTTP client: check system TLS/DNS configuration")
})
}
#[derive(Debug, Clone)]
pub struct StubState {
pub(crate) stub: Stub,
cycler: Arc<RuleCycler>,
}
impl StubState {
#[must_use]
pub fn new(stub: Stub) -> Self {
Self {
stub,
cycler: Arc::new(RuleCycler::new()),
}
}
#[must_use]
pub fn get_next_response(&self) -> Option<&StubResponse> {
let responses = &self.stub.responses;
if responses.is_empty() {
return None;
}
let repeat_for_response = |idx| responses.get(idx as usize).and_then(|r| r.get_repeat());
let response_idx = self
.cycler
.get_response_index_advance(responses.len() as u32, repeat_for_response);
responses.get(response_idx as usize)
}
#[must_use]
pub fn peek_response(&self) -> Option<&StubResponse> {
let responses = &self.stub.responses;
let response_idx = self.cycler.peek_response_index(responses.len() as u32);
self.stub.responses.get(response_idx as usize)
}
}
pub struct Imposter {
pub config: ImposterConfig,
pub stubs: RwLock<Vec<StubState>>,
pub recording_store: Arc<RecordingStore>,
pub recorded_requests: RwLock<Vec<RecordedRequest>>,
pub request_count: AtomicU64,
pub enabled: AtomicBool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub shutdown_tx: Option<broadcast::Sender<()>>,
pub flow_store: Arc<dyn FlowStore>,
}
impl Imposter {
pub fn new(config: ImposterConfig) -> Self {
let stubs: Vec<StubState> = config
.stubs
.iter()
.map(|stub| StubState::new(stub.clone()))
.collect();
let proxy_mode = Self::extract_proxy_mode(&config.stubs);
let flow_store = Self::create_flow_store(&config);
Self {
config,
stubs: RwLock::new(stubs),
recording_store: Arc::new(RecordingStore::new(proxy_mode)),
recorded_requests: RwLock::new(Vec::new()),
request_count: AtomicU64::new(0),
enabled: AtomicBool::new(true),
created_at: chrono::Utc::now(),
shutdown_tx: None,
flow_store,
}
}
pub fn replace_stubs(&self, new_stubs: Vec<Stub>) {
let mut stubs = self.stubs.write();
stubs.clear();
stubs.extend(new_stubs.into_iter().map(StubState::new));
}
fn create_flow_store(config: &ImposterConfig) -> Arc<dyn FlowStore> {
let Some(ref rift_config) = config.rift else {
return Arc::new(NoOpFlowStore);
};
let Some(ref flow_state_config) = rift_config.flow_state else {
return Arc::new(NoOpFlowStore);
};
match flow_state_config.backend.as_str() {
"inmemory" => {
info!(
"Creating InMemory FlowStore for imposter (ttl={}s)",
flow_state_config.ttl_seconds
);
Arc::new(InMemoryFlowStore::new(flow_state_config.ttl_seconds as u64))
}
"redis" => Self::create_redis_flow_store(flow_state_config),
other => {
warn!("Unknown flow state backend '{}', using NoOp", other);
Arc::new(NoOpFlowStore)
}
}
}
#[allow(unused_variables)]
fn create_redis_flow_store(
flow_state_config: &crate::imposter::types::RiftFlowStateConfig,
) -> Arc<dyn FlowStore> {
#[cfg(feature = "redis-backend")]
{
let Some(ref redis_config) = flow_state_config.redis else {
error!("Redis backend selected but no redis config provided, falling back to NoOp");
return Arc::new(NoOpFlowStore);
};
use crate::backends::RedisFlowStore;
match RedisFlowStore::new(
&redis_config.url,
redis_config.pool_size,
redis_config.key_prefix.clone(),
flow_state_config.ttl_seconds,
) {
Ok(store) => {
info!(
"Created Redis FlowStore for imposter (url={}, ttl={}s)",
redis_config.url, flow_state_config.ttl_seconds
);
Arc::new(store)
}
Err(e) => {
error!(
"Failed to create Redis FlowStore: {}, falling back to NoOp",
e
);
Arc::new(NoOpFlowStore)
}
}
}
#[cfg(not(feature = "redis-backend"))]
{
error!("Redis backend not available (compile with --features redis-backend), falling back to NoOp");
Arc::new(NoOpFlowStore)
}
}
fn extract_proxy_mode(stubs: &[Stub]) -> ProxyMode {
for stub in stubs {
for response in &stub.responses {
if let StubResponse::Proxy { proxy } = response {
return match proxy.mode.to_lowercase().as_str() {
"proxyonce" => ProxyMode::ProxyOnce,
"proxyalways" => ProxyMode::ProxyAlways,
"proxytransparent" | "" => ProxyMode::ProxyTransparent,
_ => ProxyMode::ProxyTransparent,
};
}
}
}
ProxyMode::ProxyTransparent
}
pub fn find_matching_stub(
&self,
method: &str,
path: &str,
headers: &hyper::HeaderMap,
query: Option<&str>,
body: Option<&str>,
) -> Option<(StubState, usize)> {
self.find_matching_stub_with_client(method, path, headers, query, body, None, None)
}
#[allow(clippy::too_many_arguments)]
pub fn find_matching_stub_with_client(
&self,
method: &str,
path: &str,
headers: &hyper::HeaderMap,
query: Option<&str>,
body: Option<&str>,
request_from: Option<&str>,
client_ip: Option<&str>,
) -> Option<(StubState, usize)> {
let stubs = self.stubs.read();
let headers_map = Self::header_map_to_hashmap(headers);
let form = Self::parse_form_data(headers, body);
let imposter_port = self.config.port.unwrap_or(0);
for (index, stub_state) in stubs.iter().enumerate() {
let stub = &stub_state.stub;
if stub_matches(
&stub.predicates,
method,
path,
query,
&headers_map,
body,
request_from,
client_ip,
form.as_ref(),
imposter_port,
) {
return Some((stub_state.clone(), index));
}
}
None
}
fn parse_form_data(
headers: &hyper::HeaderMap,
body: Option<&str>,
) -> Option<HashMap<String, String>> {
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if content_type.contains("application/x-www-form-urlencoded") {
if let Some(body_str) = body {
let mut map = HashMap::new();
for pair in body_str.split('&').filter(|s| !s.is_empty()) {
let mut parts = pair.splitn(2, '=');
if let Some(raw_key) = parts.next() {
let key = urlencoding::decode(raw_key)
.unwrap_or_default()
.into_owned();
let value = parts
.next()
.map(|v| urlencoding::decode(v).unwrap_or_default().into_owned())
.unwrap_or_default();
map.entry(key)
.and_modify(|existing: &mut String| {
existing.push(',');
existing.push_str(&value);
})
.or_insert(value);
}
}
return Some(map);
}
}
None
}
pub fn get_all_stubs_info(&self) -> Vec<DebugStubInfo> {
let stubs = self.stubs.read();
stubs
.iter()
.map(|stub_state| &stub_state.stub)
.enumerate()
.map(|(index, stub)| DebugStubInfo {
index,
id: stub.id.clone(),
predicates: stub.predicates.clone(),
response_count: stub.responses.len(),
})
.collect()
}
pub fn get_debug_imposter_info(&self) -> DebugImposter {
let stubs = self.stubs.read();
DebugImposter {
port: self.config.port.unwrap_or(0),
name: self.config.name.clone(),
protocol: self.config.protocol.clone(),
stub_count: stubs.len(),
}
}
pub fn get_response_preview(&self, stub_state: &StubState) -> DebugResponsePreview {
if stub_state.stub.responses.is_empty() {
return DebugResponsePreview {
response_type: "unknown".to_string(),
status_code: None,
headers: None,
body_preview: None,
};
}
if let Some(response) = stub_state.peek_response() {
return create_response_preview(response);
}
DebugResponsePreview {
response_type: "unknown".to_string(),
status_code: None,
headers: None,
body_preview: None,
}
}
pub(crate) fn header_map_to_hashmap(headers: &hyper::HeaderMap) -> HashMap<String, String> {
headers
.iter()
.map(|(k, v)| {
(
crate::behaviors::header_to_title_case(k.as_str()),
v.to_str().unwrap_or("").to_string(),
)
})
.collect()
}
#[allow(clippy::type_complexity)]
pub fn execute_stub_with_rift(
&self,
stub_state: &StubState,
) -> Option<(
u16,
HashMap<String, String>,
String,
Option<serde_json::Value>,
Option<RiftResponseExtension>,
ResponseMode,
bool,
)> {
let response = stub_state.get_next_response()?;
execute_stub_response_with_rift(response)
}
pub fn get_rift_script_response(&self, stub_state: &StubState) -> Option<RiftScriptConfig> {
let response = stub_state.peek_response()?;
get_rift_script_config(response)
}
pub fn advance_cycler_for_rift_script(&self, stub_state: &StubState) {
_ = stub_state.get_next_response();
}
pub fn get_proxy_response(&self, stub: &StubState) -> Option<ProxyResponse> {
let response = stub.peek_response()?;
match response {
StubResponse::Proxy { proxy } => Some(proxy.clone()),
_ => None,
}
}
pub fn advance_cycler_for_proxy(&self, stub_state: &StubState) {
_ = stub_state.get_next_response();
}
pub fn get_inject_response(&self, stub_state: &StubState) -> Option<String> {
let response = stub_state.peek_response()?;
match response {
StubResponse::Inject { inject } => Some(inject.clone()),
_ => None,
}
}
pub fn advance_cycler_for_inject(&self, stub_state: &StubState) {
_ = stub_state.get_next_response();
}
fn generate_predicates_from_request(
&self,
generators: &[serde_json::Value],
method: &str,
path: &str,
headers: &HashMap<String, String>,
body: Option<&str>,
query: Option<&str>,
) -> Vec<serde_json::Value> {
let mut predicates = Vec::new();
for gen in generators {
let gen_obj = match gen.as_object() {
Some(obj) => obj,
None => continue,
};
if let Some(inject_fn) = gen_obj.get("inject").and_then(|v| v.as_str()) {
#[cfg(feature = "javascript")]
{
use crate::scripting::{execute_predicate_generator_inject, MountebankRequest};
let query_map = query
.map(crate::imposter::parse_query_string)
.unwrap_or_default();
let mb_request = MountebankRequest {
method: method.to_string(),
path: path.to_string(),
query: query_map,
headers: headers.clone(),
body: body.map(|b| b.to_string()),
};
let inject_preds =
execute_predicate_generator_inject(inject_fn, &mb_request, &predicates);
predicates.extend(inject_preds);
}
#[cfg(not(feature = "javascript"))]
{
tracing::warn!("predicateGenerator inject requires the 'javascript' feature; generator ignored");
let _ = inject_fn;
}
continue;
}
let matches = match gen_obj.get("matches").and_then(|m| m.as_object()) {
Some(m) => m,
None => continue,
};
let case_sensitive = gen_obj
.get("caseSensitive")
.and_then(|c| c.as_bool())
.unwrap_or(true);
let predicate_operator = gen_obj
.get("predicateOperator")
.and_then(|p| p.as_str())
.unwrap_or("equals");
let except_pattern = gen_obj.get("except").and_then(|e| e.as_str());
let mut pred_values = serde_json::Map::new();
if matches
.get("path")
.and_then(|p| p.as_bool())
.unwrap_or(false)
{
let mut path_val = path.to_string();
if let Some(pattern) = except_pattern {
if let Ok(re) = regex::Regex::new(pattern) {
path_val = re.replace_all(&path_val, "").to_string();
}
}
pred_values.insert("path".to_string(), serde_json::Value::String(path_val));
}
if matches
.get("method")
.and_then(|m| m.as_bool())
.unwrap_or(false)
{
let mut method_val = method.to_string();
if let Some(pattern) = except_pattern {
if let Ok(re) = regex::Regex::new(pattern) {
method_val = re.replace_all(&method_val, "").to_string();
}
}
pred_values.insert("method".to_string(), serde_json::Value::String(method_val));
}
if matches
.get("query")
.and_then(|q| q.as_bool())
.unwrap_or(false)
{
if let Some(query_str) = query {
let query_map = crate::imposter::parse_query_string(query_str);
if !query_map.is_empty() {
let query_json: serde_json::Map<String, serde_json::Value> = query_map
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect();
pred_values
.insert("query".to_string(), serde_json::Value::Object(query_json));
}
}
}
if let Some(header_matches) = matches.get("headers").and_then(|h| h.as_object()) {
let mut header_preds = serde_json::Map::new();
for (header_name, should_match) in header_matches {
if should_match.as_bool().unwrap_or(false) {
if let Some(header_value) = headers.get(header_name) {
header_preds.insert(
header_name.clone(),
serde_json::Value::String(header_value.clone()),
);
}
}
}
if !header_preds.is_empty() {
pred_values.insert(
"headers".to_string(),
serde_json::Value::Object(header_preds),
);
}
}
if matches
.get("body")
.and_then(|b| b.as_bool())
.unwrap_or(false)
{
if let Some(body_str) = body {
let mut body_val = body_str.to_string();
if let Some(pattern) = except_pattern {
if let Ok(re) = regex::Regex::new(pattern) {
body_val = re.replace_all(&body_val, "").to_string();
}
}
pred_values.insert("body".to_string(), serde_json::Value::String(body_val));
}
}
if pred_values.is_empty() {
continue;
}
let mut predicate = serde_json::Map::new();
predicate.insert(
predicate_operator.to_string(),
serde_json::Value::Object(pred_values),
);
predicate.insert(
"caseSensitive".to_string(),
serde_json::Value::Bool(case_sensitive),
);
predicates.push(serde_json::Value::Object(predicate));
}
predicates
}
pub fn insert_generated_stub(&self, stub: Stub, before_index: usize) {
let new_stub_state = StubState::new(stub);
let mut stubs = self.stubs.write();
let index = before_index.min(stubs.len());
stubs.insert(index, new_stub_state);
debug!("Inserted generated stub at index {}", index);
}
pub fn insert_or_append_proxy_stub(&self, stub: Stub, proxy_to: &str, proxy_mode: &str) {
let mut stubs = self.stubs.write();
let proxy_stub_index = stubs
.iter()
.position(|s| {
s.stub
.responses
.iter()
.any(|r| matches!(r, StubResponse::Proxy { proxy } if proxy.to == proxy_to))
})
.unwrap_or(stubs.len());
if proxy_mode == "proxyAlways" {
let matching_stub_idx = stubs
.iter()
.map(|stub_state| &stub_state.stub)
.enumerate()
.skip(proxy_stub_index + 1) .find(|(_, existing)| {
let existing_preds =
serde_json::to_string(&existing.predicates).unwrap_or_default();
let new_preds = serde_json::to_string(&stub.predicates).unwrap_or_default();
existing_preds == new_preds && !existing.predicates.is_empty()
})
.map(|(idx, _)| idx);
if let Some(idx) = matching_stub_idx {
stubs[idx].stub.responses.extend(stub.responses);
debug!(
"Appended response to existing stub at index {} (proxyAlways mode, {} total responses)",
idx,
stubs[idx].stub.responses.len()
);
return;
}
let insert_index = (proxy_stub_index + 1).min(stubs.len());
stubs.insert(insert_index, StubState::new(stub));
debug!(
"Inserted generated stub at index {} after proxy (proxyAlways mode)",
insert_index
);
} else {
let index = proxy_stub_index.min(stubs.len());
stubs.insert(index, StubState::new(stub));
debug!(
"Inserted generated stub at index {} before proxy (proxyOnce mode)",
index
);
}
}
pub async fn handle_proxy_request(
&self,
proxy_config: &ProxyResponse,
method: &str,
uri: &hyper::Uri,
headers: &HashMap<String, String>,
body: Option<&str>,
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>, Option<u64>)> {
let client = get_http_client();
info!("Proxy config - addDecorateBehavior: {:?}, addWaitBehavior: {}, predicateGenerators: {:?}",
proxy_config.add_decorate_behavior, proxy_config.add_wait_behavior, proxy_config.predicate_generators);
let original_path = uri.path();
let rewritten_path = if let Some(ref rewrite) = proxy_config.path_rewrite {
original_path.replacen(&rewrite.from, &rewrite.to, 1)
} else {
original_path.to_string()
};
let target_url = format!(
"{}{}{}",
proxy_config.to,
rewritten_path,
uri.query().map(|q| format!("?{q}")).unwrap_or_default()
);
if proxy_config.path_rewrite.is_some() {
debug!(
"Proxy request to: {} (path rewritten from '{}')",
target_url, original_path
);
} else {
debug!("Proxy request to: {}", target_url);
}
let signature = RequestSignature::new(method, uri.path(), uri.query(), &[]);
if !self.recording_store.should_proxy(&signature) {
if let Some(recorded) = self.recording_store.get_recorded(&signature) {
debug!("Returning recorded proxy response (proxyOnce mode)");
return Ok((
recorded.status,
recorded.headers.clone(),
recorded.body.clone(),
recorded.latency_ms,
));
}
}
let start = Instant::now();
let mut request = match method.to_uppercase().as_str() {
"GET" => client.get(&target_url),
"POST" => client.post(&target_url),
"PUT" => client.put(&target_url),
"DELETE" => client.delete(&target_url),
"PATCH" => client.patch(&target_url),
"HEAD" => client.head(&target_url),
_ => client.get(&target_url),
};
for (key, value) in headers {
let key_lower = key.to_lowercase();
if key_lower != "host" && key_lower != "content-length" {
request = request.header(key, value);
}
}
for (key, value) in &proxy_config.inject_headers {
request = request.header(key, value);
}
if let Some(body_str) = body {
request = request.body(body_str.to_string());
}
let response = request
.send()
.await
.with_context(|| format!("Failed to send proxy request to {}", target_url))?;
let latency_ms = start.elapsed().as_millis() as u64;
let status = response.status().as_u16();
let response_headers: Vec<(String, String)> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
if let Some(content_length) = response.content_length() {
if content_length as usize > MAX_PROXY_RESPONSE_BODY_SIZE {
anyhow::bail!(
"Proxy response body from {} exceeds maximum size ({} > {} bytes)",
target_url,
content_length,
MAX_PROXY_RESPONSE_BODY_SIZE
);
}
}
let body_bytes = response
.bytes()
.await
.with_context(|| format!("Failed to read response body from {}", target_url))?;
if body_bytes.len() > MAX_PROXY_RESPONSE_BODY_SIZE {
anyhow::bail!(
"Proxy response body from {} exceeds maximum size ({} > {} bytes)",
target_url,
body_bytes.len(),
MAX_PROXY_RESPONSE_BODY_SIZE
);
}
let recorded_response = RecordedResponse {
status,
headers: response_headers.clone(),
body: body_bytes.to_vec(),
latency_ms: if proxy_config.add_wait_behavior {
Some(latency_ms)
} else {
None
},
timestamp_secs: crate::util::unix_timestamp(),
};
self.recording_store.record(signature, recorded_response);
if !proxy_config.predicate_generators.is_empty()
|| proxy_config.add_wait_behavior
|| proxy_config.add_decorate_behavior.is_some()
{
let predicates = if !proxy_config.predicate_generators.is_empty() {
self.generate_predicates_from_request(
&proxy_config.predicate_generators,
method,
uri.path(),
headers,
body,
uri.query(),
)
} else {
vec![]
};
let latency_for_stub = if proxy_config.add_wait_behavior {
Some(latency_ms)
} else {
None
};
let new_stub = create_stub_from_proxy_response(
predicates,
status,
&response_headers,
&body_bytes,
latency_for_stub,
proxy_config.add_decorate_behavior.clone(),
Some(proxy_config.to.clone()),
);
let mode = if proxy_config.mode.is_empty() {
"proxyOnce"
} else {
&proxy_config.mode
};
self.insert_or_append_proxy_stub(new_stub, &proxy_config.to, mode);
debug!(
"Generated stub from proxy response for path {} (mode: {})",
uri.path(),
mode
);
}
Ok((
status,
response_headers,
body_bytes.to_vec(),
if proxy_config.add_wait_behavior {
Some(latency_ms)
} else {
None
},
))
}
pub fn record_request(&self, req: &RecordedRequest) {
if self.config.record_requests {
let mut requests = self.recorded_requests.write();
if requests.len() >= MAX_RECORDED_REQUESTS {
tracing::warn!(
port = self.config.port,
max = MAX_RECORDED_REQUESTS,
"Recorded requests cap reached; oldest entry evicted"
);
requests.remove(0);
}
requests.push(req.clone());
}
}
pub fn get_recorded_requests(&self) -> Vec<RecordedRequest> {
self.recorded_requests.read().clone()
}
pub fn clear_recorded_requests(&self) {
self.recorded_requests.write().clear();
self.request_count.store(0, Ordering::SeqCst);
}
pub fn clear_proxy_responses(&self) {
self.recording_store.clear();
}
pub fn increment_request_count(&self) -> u64 {
self.request_count.fetch_add(1, Ordering::SeqCst)
}
pub fn get_request_count(&self) -> u64 {
self.request_count.load(Ordering::SeqCst)
}
pub fn add_stub(&self, stub: Stub, index: Option<usize>) {
let mut stubs = self.stubs.write();
let idx = index.unwrap_or(stubs.len());
let idx = idx.min(stubs.len());
stubs.insert(idx, StubState::new(stub));
}
pub fn replace_stub(&self, index: usize, stub: Stub) -> Result<(), String> {
let mut stubs = self.stubs.write();
if index >= stubs.len() {
return Err(format!("Stub index {index} out of bounds"));
}
stubs[index].stub = stub;
Ok(())
}
pub fn delete_stub(&self, index: usize) -> Result<(), String> {
let mut stubs = self.stubs.write();
if index >= stubs.len() {
return Err(format!("Stub index {index} out of bounds"));
}
stubs.remove(index);
Ok(())
}
pub fn get_stubs(&self) -> Vec<Stub> {
self.stubs
.read()
.iter()
.map(|stub_state| stub_state.stub.clone())
.collect()
}
pub fn get_stub(&self, index: usize) -> Option<Stub> {
let stubs = self.stubs.read();
stubs.get(index).map(|stub_state| stub_state.stub.clone())
}
pub fn set_enabled(&self, enabled: bool) {
self.enabled.store(enabled, Ordering::SeqCst);
}
pub fn is_enabled(&self) -> bool {
self.enabled.load(Ordering::SeqCst)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::imposter::types::ImposterConfig;
use serde_json::json;
fn make_test_imposter() -> Imposter {
let config = ImposterConfig {
port: Some(0),
protocol: "http".to_string(),
..Default::default()
};
Imposter::new(config)
}
#[test]
fn test_parse_form_data_multi_valued_fields() {
let mut headers = hyper::HeaderMap::new();
headers.insert(
hyper::header::CONTENT_TYPE,
"application/x-www-form-urlencoded".parse().unwrap(),
);
let result = Imposter::parse_form_data(&headers, Some("checkbox=A&checkbox=B&checkbox=C"));
let form = result.expect("Should parse form data");
assert_eq!(
form.get("checkbox").unwrap(),
"A,B,C",
"Multi-valued form fields should be comma-joined"
);
}
#[test]
fn test_generator_always_writes_case_sensitive() {
let imposter = make_test_imposter();
let generators = vec![json!({
"matches": { "method": true, "path": true }
})];
let headers = HashMap::new();
let predicates = imposter.generate_predicates_from_request(
&generators,
"GET",
"/API/Users",
&headers,
None,
None,
);
assert_eq!(predicates.len(), 1);
let pred_json = &predicates[0];
assert_eq!(
pred_json.get("caseSensitive"),
Some(&serde_json::Value::Bool(true)),
"Generator should always write caseSensitive to the predicate JSON"
);
let pred: crate::imposter::types::Predicate = serde_json::from_value(pred_json.clone())
.expect("Generated predicate should deserialize");
assert_eq!(
pred.parameters.case_sensitive,
Some(true),
"Matcher should see caseSensitive=true from the generated predicate"
);
}
#[test]
fn test_generator_except_applied_to_method() {
let imposter = make_test_imposter();
let generators = vec![json!({
"matches": { "method": true },
"except": "^POST$"
})];
let headers = HashMap::new();
let predicates = imposter.generate_predicates_from_request(
&generators,
"POST",
"/test",
&headers,
None,
None,
);
assert_eq!(predicates.len(), 1);
let pred_json = &predicates[0];
let method_val = pred_json["equals"]["method"].as_str().unwrap();
assert_eq!(
method_val, "",
"except pattern should be applied to method in predicate generator"
);
}
#[test]
fn test_generator_includes_query_parameters() {
let imposter = make_test_imposter();
let generators = vec![json!({
"matches": { "path": true, "query": true }
})];
let headers = HashMap::new();
let predicates = imposter.generate_predicates_from_request(
&generators,
"GET",
"/search",
&headers,
None,
Some("q=hello&page=1"),
);
assert_eq!(predicates.len(), 1);
let pred_json = &predicates[0];
let equals_obj = pred_json["equals"].as_object().unwrap();
assert!(
equals_obj.contains_key("path"),
"Path should be in generated predicate"
);
assert!(
equals_obj.contains_key("query"),
"Query should be in generated predicate"
);
let query_obj = equals_obj["query"].as_object().unwrap();
assert_eq!(query_obj["q"].as_str().unwrap(), "hello");
assert_eq!(query_obj["page"].as_str().unwrap(), "1");
}
#[cfg(feature = "javascript")]
#[test]
fn test_generator_inject_produces_predicates() {
let imposter = make_test_imposter();
let inject_fn = r#"function(config, logger, predicates) {
return [{ equals: { path: config.request.path } }];
}"#;
let generators = vec![json!({ "inject": inject_fn })];
let headers = HashMap::new();
let predicates = imposter.generate_predicates_from_request(
&generators,
"GET",
"/api/users",
&headers,
None,
None,
);
assert_eq!(predicates.len(), 1);
let equals = predicates[0].get("equals").expect("should have equals key");
assert_eq!(equals["path"], "/api/users");
}
#[cfg(feature = "javascript")]
#[test]
fn test_generator_inject_receives_existing_predicates() {
let imposter = make_test_imposter();
let inject_fn = r#"function(config, logger, predicates) {
var result = predicates.slice();
result.push({ equals: { path: config.request.path } });
return result;
}"#;
let generators = vec![
json!({ "matches": { "method": true } }),
json!({ "inject": inject_fn }),
];
let headers = HashMap::new();
let predicates = imposter.generate_predicates_from_request(
&generators,
"POST",
"/orders",
&headers,
None,
None,
);
assert_eq!(predicates.len(), 3);
}
#[test]
fn test_record_request_cap_enforced() {
let config = ImposterConfig {
port: Some(0),
protocol: "http".to_string(),
record_requests: true,
..Default::default()
};
let imposter = Imposter::new(config);
let req = RecordedRequest {
request_from: "127.0.0.1".to_string(),
method: "GET".to_string(),
path: "/".to_string(),
query: std::collections::HashMap::new(),
headers: std::collections::HashMap::new(),
body: None,
timestamp: "2026-01-01T00:00:00Z".to_string(),
};
for _ in 0..MAX_RECORDED_REQUESTS + 10 {
imposter.record_request(&req);
}
let recorded = imposter.recorded_requests.read();
assert_eq!(
recorded.len(),
MAX_RECORDED_REQUESTS,
"Recorded requests must not exceed the cap"
);
}
#[cfg(feature = "javascript")]
#[test]
fn test_generator_inject_bad_function_returns_empty() {
let imposter = make_test_imposter();
let generators = vec![json!({ "inject": "not a function" })];
let headers = HashMap::new();
let predicates = imposter.generate_predicates_from_request(
&generators,
"GET",
"/test",
&headers,
None,
None,
);
assert!(predicates.is_empty());
}
}