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, Default)]
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 StubFaultInjectionConfig {
116    /// Create a simple HTTP error injection config
117    #[must_use]
118    pub fn http_error(codes: Vec<u16>) -> Self {
119        Self {
120            enabled: true,
121            http_errors: Some(codes),
122            http_error_probability: Some(1.0),
123            ..Default::default()
124        }
125    }
126
127    /// Create a timeout error injection config
128    #[must_use]
129    pub fn timeout(ms: u64) -> Self {
130        Self {
131            enabled: true,
132            timeout_error: true,
133            timeout_ms: Some(ms),
134            timeout_probability: Some(1.0),
135            ..Default::default()
136        }
137    }
138
139    /// Create a connection error injection config
140    #[must_use]
141    pub fn connection_error() -> Self {
142        Self {
143            enabled: true,
144            connection_error: true,
145            connection_error_probability: Some(1.0),
146            ..Default::default()
147        }
148    }
149}
150
151/// A response stub for mocking API endpoints
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ResponseStub {
154    /// HTTP method (GET, POST, PUT, DELETE, etc.)
155    pub method: String,
156    /// Path pattern (supports {{`path_params`}})
157    pub path: String,
158    /// HTTP status code
159    pub status: u16,
160    /// Response headers
161    pub headers: HashMap<String, String>,
162    /// Response body (supports templates like {{uuid}}, {{faker.name}})
163    pub body: Value,
164    /// Optional latency in milliseconds
165    pub latency_ms: Option<u64>,
166    /// Optional state machine configuration for stateful behavior
167    /// When set, responses will vary based on resource state
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub state_machine: Option<StateMachineConfig>,
170    /// Optional fault injection configuration for error simulation
171    /// When set, can inject errors, timeouts, or connection failures
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub fault_injection: Option<StubFaultInjectionConfig>,
174}
175
176impl ResponseStub {
177    /// Create a new response stub
178    pub fn new(method: impl Into<String>, path: impl Into<String>, body: Value) -> Self {
179        Self {
180            method: method.into(),
181            path: path.into(),
182            status: 200,
183            headers: HashMap::new(),
184            body,
185            latency_ms: None,
186            state_machine: None,
187            fault_injection: None,
188        }
189    }
190
191    /// Set the HTTP status code
192    #[must_use]
193    pub const fn status(mut self, status: u16) -> Self {
194        self.status = status;
195        self
196    }
197
198    /// Add a response header
199    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
200        self.headers.insert(key.into(), value.into());
201        self
202    }
203
204    /// Set response latency in milliseconds
205    #[must_use]
206    pub const fn latency(mut self, ms: u64) -> Self {
207        self.latency_ms = Some(ms);
208        self
209    }
210
211    /// Set state machine configuration for stateful behavior
212    #[must_use]
213    pub fn with_state_machine(mut self, config: StateMachineConfig) -> Self {
214        self.state_machine = Some(config);
215        self
216    }
217
218    /// Check if this stub has state machine configuration
219    #[must_use]
220    pub const fn has_state_machine(&self) -> bool {
221        self.state_machine.is_some()
222    }
223
224    /// Get state machine configuration
225    #[must_use]
226    pub const fn state_machine(&self) -> Option<&StateMachineConfig> {
227        self.state_machine.as_ref()
228    }
229
230    /// Apply state-based response override if state machine is configured
231    ///
232    /// This method checks if the stub has state machine configuration and applies
233    /// state-based response overrides based on the current state.
234    ///
235    /// Returns a modified stub with state-specific overrides applied, or the original
236    /// stub if no state machine config or no override for current state.
237    #[must_use]
238    pub fn apply_state_override(&self, current_state: &str) -> Self {
239        let mut stub = self.clone();
240
241        if let Some(ref state_machine) = self.state_machine {
242            if let Some(ref state_responses) = state_machine.state_responses {
243                if let Some(override_config) = state_responses.get(current_state) {
244                    // Apply status override
245                    if let Some(status) = override_config.status {
246                        stub.status = status;
247                    }
248
249                    // Apply body override
250                    if let Some(ref body) = override_config.body {
251                        stub.body = body.clone();
252                    }
253
254                    // Merge headers
255                    if let Some(ref headers) = override_config.headers {
256                        for (key, value) in headers {
257                            stub.headers.insert(key.clone(), value.clone());
258                        }
259                    }
260                }
261            }
262        }
263
264        stub
265    }
266
267    /// Set fault injection configuration
268    #[must_use]
269    pub fn with_fault_injection(mut self, config: StubFaultInjectionConfig) -> Self {
270        self.fault_injection = Some(config);
271        self
272    }
273
274    /// Check if this stub has fault injection configured
275    #[must_use]
276    pub fn has_fault_injection(&self) -> bool {
277        self.fault_injection.as_ref().is_some_and(|f| f.enabled)
278    }
279
280    /// Get fault injection configuration
281    #[must_use]
282    pub const fn fault_injection(&self) -> Option<&StubFaultInjectionConfig> {
283        self.fault_injection.as_ref()
284    }
285}
286
287impl ResourceIdExtractConfig {
288    /// Convert to core's `ResourceIdExtract` enum
289    #[must_use]
290    pub fn to_core(&self) -> CoreResourceIdExtract {
291        match self {
292            Self::PathParam { param } => CoreResourceIdExtract::PathParam {
293                param: param.clone(),
294            },
295            Self::JsonPath { path } => CoreResourceIdExtract::JsonPath { path: path.clone() },
296            Self::Header { name } => CoreResourceIdExtract::Header { name: name.clone() },
297            Self::QueryParam { param } => CoreResourceIdExtract::QueryParam {
298                param: param.clone(),
299            },
300        }
301    }
302}
303
304/// Dynamic stub with runtime response generation
305pub struct DynamicStub {
306    /// HTTP method
307    pub method: String,
308    /// Path pattern
309    pub path: String,
310    /// HTTP status code (can be dynamic)
311    pub status: Arc<RwLock<u16>>,
312    /// Response headers (can be dynamic)
313    pub headers: Arc<RwLock<HashMap<String, String>>>,
314    /// Dynamic response function
315    pub response_fn: DynamicResponseFn,
316    /// Optional latency in milliseconds
317    pub latency_ms: Option<u64>,
318}
319
320impl DynamicStub {
321    /// Create a new dynamic stub
322    pub fn new<F>(method: impl Into<String>, path: impl Into<String>, response_fn: F) -> Self
323    where
324        F: Fn(&RequestContext) -> Value + Send + Sync + 'static,
325    {
326        Self {
327            method: method.into(),
328            path: path.into(),
329            status: Arc::new(RwLock::new(200)),
330            headers: Arc::new(RwLock::new(HashMap::new())),
331            response_fn: Arc::new(response_fn),
332            latency_ms: None,
333        }
334    }
335
336    /// Set the HTTP status code
337    pub async fn set_status(&self, status: u16) {
338        *self.status.write().await = status;
339    }
340
341    /// Get the current status code
342    pub async fn get_status(&self) -> u16 {
343        *self.status.read().await
344    }
345
346    /// Add a response header
347    pub async fn add_header(&self, key: String, value: String) {
348        self.headers.write().await.insert(key, value);
349    }
350
351    /// Remove a response header
352    pub async fn remove_header(&self, key: &str) {
353        self.headers.write().await.remove(key);
354    }
355
356    /// Get all headers (returns a clone)
357    ///
358    /// For more efficient read-only access, consider using `with_headers()` instead.
359    pub async fn get_headers(&self) -> HashMap<String, String> {
360        self.headers.read().await.clone()
361    }
362
363    /// Access headers without cloning via a callback
364    ///
365    /// This is more efficient than `get_headers()` when you only need to
366    /// read header values without modifying them.
367    ///
368    /// # Examples
369    ///
370    /// ```rust
371    /// # use mockforge_sdk::DynamicStub;
372    /// # use serde_json::json;
373    /// # async fn example() {
374    /// let stub = DynamicStub::new("GET", "/test", |_| json!({}));
375    /// stub.add_header("X-Custom".to_string(), "value".to_string()).await;
376    ///
377    /// // Efficient read-only access
378    /// let has_custom = stub.with_headers(|headers| {
379    ///     headers.contains_key("X-Custom")
380    /// }).await;
381    /// # }
382    /// ```
383    pub async fn with_headers<F, R>(&self, f: F) -> R
384    where
385        F: FnOnce(&HashMap<String, String>) -> R,
386    {
387        let headers = self.headers.read().await;
388        f(&headers)
389    }
390
391    /// Generate a response for a given request context
392    #[must_use]
393    pub fn generate_response(&self, ctx: &RequestContext) -> Value {
394        (self.response_fn)(ctx)
395    }
396
397    /// Set latency
398    #[must_use]
399    pub const fn with_latency(mut self, ms: u64) -> Self {
400        self.latency_ms = Some(ms);
401        self
402    }
403}
404
405/// Builder for creating response stubs
406pub struct StubBuilder {
407    method: String,
408    path: String,
409    status: u16,
410    headers: HashMap<String, String>,
411    body: Value,
412    latency_ms: Option<u64>,
413    state_machine: Option<StateMachineConfig>,
414    fault_injection: Option<StubFaultInjectionConfig>,
415}
416
417impl StubBuilder {
418    /// Create a new stub builder
419    pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
420        Self {
421            method: method.into(),
422            path: path.into(),
423            status: 200,
424            headers: HashMap::new(),
425            body: Value::Null,
426            latency_ms: None,
427            state_machine: None,
428            fault_injection: None,
429        }
430    }
431
432    /// Set the HTTP status code
433    #[must_use]
434    pub const fn status(mut self, status: u16) -> Self {
435        self.status = status;
436        self
437    }
438
439    /// Add a response header
440    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
441        self.headers.insert(key.into(), value.into());
442        self
443    }
444
445    /// Set the response body
446    #[must_use]
447    pub fn body(mut self, body: Value) -> Self {
448        self.body = body;
449        self
450    }
451
452    /// Set response latency in milliseconds
453    #[must_use]
454    pub const fn latency(mut self, ms: u64) -> Self {
455        self.latency_ms = Some(ms);
456        self
457    }
458
459    /// Set state machine configuration
460    #[must_use]
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    #[must_use]
468    pub fn fault_injection(mut self, config: StubFaultInjectionConfig) -> Self {
469        self.fault_injection = Some(config);
470        self
471    }
472
473    /// Build the response stub
474    #[must_use]
475    pub fn build(self) -> ResponseStub {
476        ResponseStub {
477            method: self.method,
478            path: self.path,
479            status: self.status,
480            headers: self.headers,
481            body: self.body,
482            latency_ms: self.latency_ms,
483            state_machine: self.state_machine,
484            fault_injection: self.fault_injection,
485        }
486    }
487}