mockforge_sdk/
stub.rs

1//! Response stub configuration
2
3use mockforge_core::ResourceIdExtract as CoreResourceIdExtract;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashMap;
7use std::sync::Arc;
8use tokio::sync::RwLock;
9
10/// Type alias for a dynamic response function
11pub type DynamicResponseFn = Arc<dyn Fn(&RequestContext) -> Value + Send + Sync>;
12
13/// Request context passed to dynamic response functions
14#[derive(Debug, Clone)]
15pub struct RequestContext {
16    /// HTTP method
17    pub method: String,
18    /// Request path
19    pub path: String,
20    /// Path parameters extracted from the URL
21    pub path_params: HashMap<String, String>,
22    /// Query parameters
23    pub query_params: HashMap<String, String>,
24    /// Request headers
25    pub headers: HashMap<String, String>,
26    /// Request body
27    pub body: Option<Value>,
28}
29
30/// State machine configuration for stateful stub responses
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct StateMachineConfig {
33    /// Resource type identifier (e.g., "order", "user", "payment")
34    pub resource_type: String,
35    /// Resource ID extraction configuration
36    #[serde(flatten)]
37    pub resource_id_extract: ResourceIdExtractConfig,
38    /// Initial state name
39    pub initial_state: String,
40    /// State-based response mappings (state name -> response override)
41    /// If provided, responses will vary based on current state
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub state_responses: Option<HashMap<String, StateResponseOverride>>,
44}
45
46/// Resource ID extraction configuration for state machines
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(tag = "extract_type", rename_all = "snake_case")]
49pub enum ResourceIdExtractConfig {
50    /// Extract from path parameter (e.g., "/orders/{order_id}" -> extract "order_id")
51    PathParam {
52        /// Path parameter name to extract
53        param: String,
54    },
55    /// Extract from JSONPath in request body
56    JsonPath {
57        /// JSONPath expression to extract the resource ID
58        path: String,
59    },
60    /// Extract from header value
61    Header {
62        /// Header name to extract the resource ID from
63        name: String,
64    },
65    /// Extract from query parameter
66    QueryParam {
67        /// Query parameter name to extract
68        param: String,
69    },
70}
71
72/// State-based response override
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct StateResponseOverride {
75    /// Optional status code override (if None, uses stub's default status)
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub status: Option<u16>,
78    /// Optional body override (if None, uses stub's default body)
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub body: Option<Value>,
81    /// Optional headers override (merged with stub's default headers)
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub headers: Option<HashMap<String, String>>,
84}
85
86/// Fault injection configuration for per-stub error and latency simulation
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct StubFaultInjectionConfig {
89    /// Enable fault injection for this stub
90    #[serde(default)]
91    pub enabled: bool,
92    /// HTTP error codes to inject (randomly selected if multiple)
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub http_errors: Option<Vec<u16>>,
95    /// Probability of injecting HTTP error (0.0-1.0, default: 1.0 if http_errors set)
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub http_error_probability: Option<f64>,
98    /// Inject timeout error (returns 504 Gateway Timeout)
99    #[serde(default)]
100    pub timeout_error: bool,
101    /// Timeout duration in milliseconds (only used if timeout_error is true)
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub timeout_ms: Option<u64>,
104    /// Probability of timeout error (0.0-1.0, default: 1.0 if timeout_error is true)
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub timeout_probability: Option<f64>,
107    /// Inject connection error (returns 503 Service Unavailable)
108    #[serde(default)]
109    pub connection_error: bool,
110    /// Probability of connection error (0.0-1.0, default: 1.0 if connection_error is true)
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub connection_error_probability: Option<f64>,
113}
114
115impl Default for StubFaultInjectionConfig {
116    fn default() -> Self {
117        Self {
118            enabled: false,
119            http_errors: None,
120            http_error_probability: None,
121            timeout_error: false,
122            timeout_ms: None,
123            timeout_probability: None,
124            connection_error: false,
125            connection_error_probability: None,
126        }
127    }
128}
129
130impl StubFaultInjectionConfig {
131    /// Create a simple HTTP error injection config
132    pub fn http_error(codes: Vec<u16>) -> Self {
133        Self {
134            enabled: true,
135            http_errors: Some(codes),
136            http_error_probability: Some(1.0),
137            ..Default::default()
138        }
139    }
140
141    /// Create a timeout error injection config
142    pub fn timeout(ms: u64) -> Self {
143        Self {
144            enabled: true,
145            timeout_error: true,
146            timeout_ms: Some(ms),
147            timeout_probability: Some(1.0),
148            ..Default::default()
149        }
150    }
151
152    /// Create a connection error injection config
153    pub fn connection_error() -> Self {
154        Self {
155            enabled: true,
156            connection_error: true,
157            connection_error_probability: Some(1.0),
158            ..Default::default()
159        }
160    }
161}
162
163/// A response stub for mocking API endpoints
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct ResponseStub {
166    /// HTTP method (GET, POST, PUT, DELETE, etc.)
167    pub method: String,
168    /// Path pattern (supports {{path_params}})
169    pub path: String,
170    /// HTTP status code
171    pub status: u16,
172    /// Response headers
173    pub headers: HashMap<String, String>,
174    /// Response body (supports templates like {{uuid}}, {{faker.name}})
175    pub body: Value,
176    /// Optional latency in milliseconds
177    pub latency_ms: Option<u64>,
178    /// Optional state machine configuration for stateful behavior
179    /// When set, responses will vary based on resource state
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub state_machine: Option<StateMachineConfig>,
182    /// Optional fault injection configuration for error simulation
183    /// When set, can inject errors, timeouts, or connection failures
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub fault_injection: Option<StubFaultInjectionConfig>,
186}
187
188impl ResponseStub {
189    /// Create a new response stub
190    pub fn new(method: impl Into<String>, path: impl Into<String>, body: Value) -> Self {
191        Self {
192            method: method.into(),
193            path: path.into(),
194            status: 200,
195            headers: HashMap::new(),
196            body,
197            latency_ms: None,
198            state_machine: None,
199            fault_injection: None,
200        }
201    }
202
203    /// Set the HTTP status code
204    pub fn status(mut self, status: u16) -> Self {
205        self.status = status;
206        self
207    }
208
209    /// Add a response header
210    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
211        self.headers.insert(key.into(), value.into());
212        self
213    }
214
215    /// Set response latency in milliseconds
216    pub fn latency(mut self, ms: u64) -> Self {
217        self.latency_ms = Some(ms);
218        self
219    }
220
221    /// Set state machine configuration for stateful behavior
222    pub fn with_state_machine(mut self, config: StateMachineConfig) -> Self {
223        self.state_machine = Some(config);
224        self
225    }
226
227    /// Check if this stub has state machine configuration
228    pub fn has_state_machine(&self) -> bool {
229        self.state_machine.is_some()
230    }
231
232    /// Get state machine configuration
233    pub fn state_machine(&self) -> Option<&StateMachineConfig> {
234        self.state_machine.as_ref()
235    }
236
237    /// Apply state-based response override if state machine is configured
238    ///
239    /// This method checks if the stub has state machine configuration and applies
240    /// state-based response overrides based on the current state.
241    ///
242    /// Returns a modified stub with state-specific overrides applied, or the original
243    /// stub if no state machine config or no override for current state.
244    pub fn apply_state_override(&self, current_state: &str) -> ResponseStub {
245        let mut stub = self.clone();
246
247        if let Some(ref state_machine) = self.state_machine {
248            if let Some(ref state_responses) = state_machine.state_responses {
249                if let Some(ref override_config) = state_responses.get(current_state) {
250                    // Apply status override
251                    if let Some(status) = override_config.status {
252                        stub.status = status;
253                    }
254
255                    // Apply body override
256                    if let Some(ref body) = override_config.body {
257                        stub.body = body.clone();
258                    }
259
260                    // Merge headers
261                    if let Some(ref headers) = override_config.headers {
262                        for (key, value) in headers {
263                            stub.headers.insert(key.clone(), value.clone());
264                        }
265                    }
266                }
267            }
268        }
269
270        stub
271    }
272
273    /// Set fault injection configuration
274    pub fn with_fault_injection(mut self, config: StubFaultInjectionConfig) -> Self {
275        self.fault_injection = Some(config);
276        self
277    }
278
279    /// Check if this stub has fault injection configured
280    pub fn has_fault_injection(&self) -> bool {
281        self.fault_injection.as_ref().map(|f| f.enabled).unwrap_or(false)
282    }
283
284    /// Get fault injection configuration
285    pub fn fault_injection(&self) -> Option<&StubFaultInjectionConfig> {
286        self.fault_injection.as_ref()
287    }
288}
289
290impl ResourceIdExtractConfig {
291    /// Convert to core's ResourceIdExtract enum
292    pub fn to_core(&self) -> CoreResourceIdExtract {
293        match self {
294            ResourceIdExtractConfig::PathParam { param } => CoreResourceIdExtract::PathParam {
295                param: param.clone(),
296            },
297            ResourceIdExtractConfig::JsonPath { path } => {
298                CoreResourceIdExtract::JsonPath { path: path.clone() }
299            }
300            ResourceIdExtractConfig::Header { name } => {
301                CoreResourceIdExtract::Header { name: name.clone() }
302            }
303            ResourceIdExtractConfig::QueryParam { param } => CoreResourceIdExtract::QueryParam {
304                param: param.clone(),
305            },
306        }
307    }
308}
309
310/// Dynamic stub with runtime response generation
311pub struct DynamicStub {
312    /// HTTP method
313    pub method: String,
314    /// Path pattern
315    pub path: String,
316    /// HTTP status code (can be dynamic)
317    pub status: Arc<RwLock<u16>>,
318    /// Response headers (can be dynamic)
319    pub headers: Arc<RwLock<HashMap<String, String>>>,
320    /// Dynamic response function
321    pub response_fn: DynamicResponseFn,
322    /// Optional latency in milliseconds
323    pub latency_ms: Option<u64>,
324}
325
326impl DynamicStub {
327    /// Create a new dynamic stub
328    pub fn new<F>(method: impl Into<String>, path: impl Into<String>, response_fn: F) -> Self
329    where
330        F: Fn(&RequestContext) -> Value + Send + Sync + 'static,
331    {
332        Self {
333            method: method.into(),
334            path: path.into(),
335            status: Arc::new(RwLock::new(200)),
336            headers: Arc::new(RwLock::new(HashMap::new())),
337            response_fn: Arc::new(response_fn),
338            latency_ms: None,
339        }
340    }
341
342    /// Set the HTTP status code
343    pub async fn set_status(&self, status: u16) {
344        *self.status.write().await = status;
345    }
346
347    /// Get the current status code
348    pub async fn get_status(&self) -> u16 {
349        *self.status.read().await
350    }
351
352    /// Add a response header
353    pub async fn add_header(&self, key: String, value: String) {
354        self.headers.write().await.insert(key, value);
355    }
356
357    /// Remove a response header
358    pub async fn remove_header(&self, key: &str) {
359        self.headers.write().await.remove(key);
360    }
361
362    /// Get all headers (returns a clone)
363    ///
364    /// For more efficient read-only access, consider using `with_headers()` instead.
365    pub async fn get_headers(&self) -> HashMap<String, String> {
366        self.headers.read().await.clone()
367    }
368
369    /// Access headers without cloning via a callback
370    ///
371    /// This is more efficient than `get_headers()` when you only need to
372    /// read header values without modifying them.
373    ///
374    /// # Examples
375    ///
376    /// ```rust
377    /// # use mockforge_sdk::DynamicStub;
378    /// # use serde_json::json;
379    /// # async fn example() {
380    /// let stub = DynamicStub::new("GET", "/test", |_| json!({}));
381    /// stub.add_header("X-Custom".to_string(), "value".to_string()).await;
382    ///
383    /// // Efficient read-only access
384    /// let has_custom = stub.with_headers(|headers| {
385    ///     headers.contains_key("X-Custom")
386    /// }).await;
387    /// # }
388    /// ```
389    pub async fn with_headers<F, R>(&self, f: F) -> R
390    where
391        F: FnOnce(&HashMap<String, String>) -> R,
392    {
393        let headers = self.headers.read().await;
394        f(&headers)
395    }
396
397    /// Generate a response for a given request context
398    pub fn generate_response(&self, ctx: &RequestContext) -> Value {
399        (self.response_fn)(ctx)
400    }
401
402    /// Set latency
403    pub fn with_latency(mut self, ms: u64) -> Self {
404        self.latency_ms = Some(ms);
405        self
406    }
407}
408
409/// Builder for creating response stubs
410pub struct StubBuilder {
411    method: String,
412    path: String,
413    status: u16,
414    headers: HashMap<String, String>,
415    body: Value,
416    latency_ms: Option<u64>,
417    state_machine: Option<StateMachineConfig>,
418    fault_injection: Option<StubFaultInjectionConfig>,
419}
420
421impl StubBuilder {
422    /// Create a new stub builder
423    pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
424        Self {
425            method: method.into(),
426            path: path.into(),
427            status: 200,
428            headers: HashMap::new(),
429            body: Value::Null,
430            latency_ms: None,
431            state_machine: None,
432            fault_injection: None,
433        }
434    }
435
436    /// Set the HTTP status code
437    pub fn status(mut self, status: u16) -> Self {
438        self.status = status;
439        self
440    }
441
442    /// Add a response header
443    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
444        self.headers.insert(key.into(), value.into());
445        self
446    }
447
448    /// Set the response body
449    pub fn body(mut self, body: Value) -> Self {
450        self.body = body;
451        self
452    }
453
454    /// Set response latency in milliseconds
455    pub fn latency(mut self, ms: u64) -> Self {
456        self.latency_ms = Some(ms);
457        self
458    }
459
460    /// Set state machine configuration
461    pub fn state_machine(mut self, config: StateMachineConfig) -> Self {
462        self.state_machine = Some(config);
463        self
464    }
465
466    /// Set fault injection configuration
467    pub fn fault_injection(mut self, config: StubFaultInjectionConfig) -> Self {
468        self.fault_injection = Some(config);
469        self
470    }
471
472    /// Build the response stub
473    pub fn build(self) -> ResponseStub {
474        ResponseStub {
475            method: self.method,
476            path: self.path,
477            status: self.status,
478            headers: self.headers,
479            body: self.body,
480            latency_ms: self.latency_ms,
481            state_machine: self.state_machine,
482            fault_injection: self.fault_injection,
483        }
484    }
485}