mockforge_sdk/
admin.rs

1//! Admin API client for runtime mock management
2//!
3//! Provides programmatic access to `MockForge`'s management API for
4//! creating, updating, and managing mocks at runtime.
5
6use crate::{Error, Result};
7use reqwest::Client;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Admin API client for managing mocks
12pub struct AdminClient {
13    base_url: String,
14    client: Client,
15}
16
17/// Mock configuration
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct MockConfig {
20    /// Unique identifier for the mock (auto-generated if empty)
21    #[serde(skip_serializing_if = "String::is_empty")]
22    pub id: String,
23    /// Human-readable name for the mock
24    pub name: String,
25    /// HTTP method (GET, POST, PUT, DELETE, etc.)
26    pub method: String,
27    /// URL path pattern (supports path parameters)
28    pub path: String,
29    /// Response configuration
30    pub response: MockResponse,
31    /// Whether this mock is currently active
32    #[serde(default = "default_true")]
33    pub enabled: bool,
34    /// Optional latency to simulate in milliseconds
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub latency_ms: Option<u64>,
37    /// HTTP status code to return (default: 200)
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub status_code: Option<u16>,
40    /// Request matching criteria (headers, query params, body patterns)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub request_match: Option<RequestMatchCriteria>,
43    /// Priority for mock ordering (higher priority mocks are matched first)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub priority: Option<i32>,
46    /// Scenario name for stateful mocking
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub scenario: Option<String>,
49    /// Required scenario state for this mock to be active
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub required_scenario_state: Option<String>,
52    /// New scenario state after this mock is matched
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub new_scenario_state: Option<String>,
55}
56
57const fn default_true() -> bool {
58    true
59}
60
61/// Mock response configuration
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct MockResponse {
64    /// Response body (supports JSON values and templates)
65    pub body: serde_json::Value,
66    /// Optional HTTP headers to include in the response
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub headers: Option<HashMap<String, String>>,
69}
70
71/// Request matching criteria for advanced request matching
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73pub struct RequestMatchCriteria {
74    /// Headers that must be present and match (case-insensitive header names)
75    #[serde(skip_serializing_if = "HashMap::is_empty")]
76    pub headers: HashMap<String, String>,
77    /// Query parameters that must be present and match
78    #[serde(skip_serializing_if = "HashMap::is_empty")]
79    pub query_params: HashMap<String, String>,
80    /// Request body pattern (supports exact match or regex)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub body_pattern: Option<String>,
83    /// `JSONPath` expression for JSON body matching
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub json_path: Option<String>,
86    /// `XPath` expression for XML body matching
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub xpath: Option<String>,
89    /// Custom matcher expression (e.g., "headers.content-type == \"application/json\"")
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub custom_matcher: Option<String>,
92}
93
94/// Server statistics
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ServerStats {
97    /// Server uptime in seconds
98    pub uptime_seconds: u64,
99    /// Total number of requests served
100    pub total_requests: u64,
101    /// Number of registered mocks (active and inactive)
102    pub active_mocks: usize,
103    /// Number of currently enabled mocks
104    pub enabled_mocks: usize,
105    /// Total number of registered routes
106    pub registered_routes: usize,
107}
108
109/// Server configuration info
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ServerConfig {
112    /// `MockForge` version
113    pub version: String,
114    /// HTTP port the server is running on
115    pub port: u16,
116    /// Whether an `OpenAPI` spec is loaded
117    pub has_openapi_spec: bool,
118    /// Path to the `OpenAPI` spec file (if loaded)
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub spec_path: Option<String>,
121}
122
123/// List of mocks with metadata
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct MockList {
126    /// List of mock configurations
127    pub mocks: Vec<MockConfig>,
128    /// Total number of mocks
129    pub total: usize,
130    /// Number of enabled mocks
131    pub enabled: usize,
132}
133
134impl AdminClient {
135    /// Create a new admin client
136    ///
137    /// The base URL should be the root URL of the `MockForge` server
138    /// (e.g., "<http://localhost:3000>"). Trailing slashes are automatically removed.
139    ///
140    /// # Examples
141    ///
142    /// ```rust
143    /// use mockforge_sdk::AdminClient;
144    ///
145    /// let client = AdminClient::new("http://localhost:3000");
146    /// // Also works with trailing slash:
147    /// let client = AdminClient::new("http://localhost:3000/");
148    /// ```
149    pub fn new(base_url: impl Into<String>) -> Self {
150        let mut url = base_url.into();
151
152        // Normalize URL: remove trailing slashes
153        while url.ends_with('/') {
154            url.pop();
155        }
156
157        Self {
158            base_url: url,
159            client: Client::new(),
160        }
161    }
162
163    /// List all mocks
164    pub async fn list_mocks(&self) -> Result<MockList> {
165        let url = format!("{}/api/mocks", self.base_url);
166        let response = self
167            .client
168            .get(&url)
169            .send()
170            .await
171            .map_err(|e| Error::General(format!("Failed to list mocks: {e}")))?;
172
173        if !response.status().is_success() {
174            return Err(Error::General(format!(
175                "Failed to list mocks: HTTP {}",
176                response.status()
177            )));
178        }
179
180        response
181            .json()
182            .await
183            .map_err(|e| Error::General(format!("Failed to parse response: {e}")))
184    }
185
186    /// Get a specific mock by ID
187    pub async fn get_mock(&self, id: &str) -> Result<MockConfig> {
188        let url = format!("{}/api/mocks/{}", self.base_url, id);
189        let response = self
190            .client
191            .get(&url)
192            .send()
193            .await
194            .map_err(|e| Error::General(format!("Failed to get mock: {e}")))?;
195
196        if response.status() == reqwest::StatusCode::NOT_FOUND {
197            return Err(Error::General(format!("Mock not found: {id}")));
198        }
199
200        if !response.status().is_success() {
201            return Err(Error::General(format!("Failed to get mock: HTTP {}", response.status())));
202        }
203
204        response
205            .json()
206            .await
207            .map_err(|e| Error::General(format!("Failed to parse response: {e}")))
208    }
209
210    /// Create a new mock
211    pub async fn create_mock(&self, mock: MockConfig) -> Result<MockConfig> {
212        let url = format!("{}/api/mocks", self.base_url);
213        let response = self
214            .client
215            .post(&url)
216            .json(&mock)
217            .send()
218            .await
219            .map_err(|e| Error::General(format!("Failed to create mock: {e}")))?;
220
221        if response.status() == reqwest::StatusCode::CONFLICT {
222            return Err(Error::General(format!("Mock with ID {} already exists", mock.id)));
223        }
224
225        if !response.status().is_success() {
226            return Err(Error::General(format!(
227                "Failed to create mock: HTTP {}",
228                response.status()
229            )));
230        }
231
232        response
233            .json()
234            .await
235            .map_err(|e| Error::General(format!("Failed to parse response: {e}")))
236    }
237
238    /// Update an existing mock
239    pub async fn update_mock(&self, id: &str, mock: MockConfig) -> Result<MockConfig> {
240        let url = format!("{}/api/mocks/{}", self.base_url, id);
241        let response = self
242            .client
243            .put(&url)
244            .json(&mock)
245            .send()
246            .await
247            .map_err(|e| Error::General(format!("Failed to update mock: {e}")))?;
248
249        if response.status() == reqwest::StatusCode::NOT_FOUND {
250            return Err(Error::General(format!("Mock not found: {id}")));
251        }
252
253        if !response.status().is_success() {
254            return Err(Error::General(format!(
255                "Failed to update mock: HTTP {}",
256                response.status()
257            )));
258        }
259
260        response
261            .json()
262            .await
263            .map_err(|e| Error::General(format!("Failed to parse response: {e}")))
264    }
265
266    /// Delete a mock
267    pub async fn delete_mock(&self, id: &str) -> Result<()> {
268        let url = format!("{}/api/mocks/{}", self.base_url, id);
269        let response = self
270            .client
271            .delete(&url)
272            .send()
273            .await
274            .map_err(|e| Error::General(format!("Failed to delete mock: {e}")))?;
275
276        if response.status() == reqwest::StatusCode::NOT_FOUND {
277            return Err(Error::General(format!("Mock not found: {id}")));
278        }
279
280        if !response.status().is_success() {
281            return Err(Error::General(format!(
282                "Failed to delete mock: HTTP {}",
283                response.status()
284            )));
285        }
286
287        Ok(())
288    }
289
290    /// Get server statistics
291    pub async fn get_stats(&self) -> Result<ServerStats> {
292        let url = format!("{}/api/stats", self.base_url);
293        let response = self
294            .client
295            .get(&url)
296            .send()
297            .await
298            .map_err(|e| Error::General(format!("Failed to get stats: {e}")))?;
299
300        if !response.status().is_success() {
301            return Err(Error::General(format!("Failed to get stats: HTTP {}", response.status())));
302        }
303
304        response
305            .json()
306            .await
307            .map_err(|e| Error::General(format!("Failed to parse response: {e}")))
308    }
309
310    /// Get server configuration
311    pub async fn get_config(&self) -> Result<ServerConfig> {
312        let url = format!("{}/api/config", self.base_url);
313        let response = self
314            .client
315            .get(&url)
316            .send()
317            .await
318            .map_err(|e| Error::General(format!("Failed to get config: {e}")))?;
319
320        if !response.status().is_success() {
321            return Err(Error::General(format!(
322                "Failed to get config: HTTP {}",
323                response.status()
324            )));
325        }
326
327        response
328            .json()
329            .await
330            .map_err(|e| Error::General(format!("Failed to parse response: {e}")))
331    }
332
333    /// Reset all mocks to initial state
334    pub async fn reset(&self) -> Result<()> {
335        let url = format!("{}/api/reset", self.base_url);
336        let response = self
337            .client
338            .post(&url)
339            .send()
340            .await
341            .map_err(|e| Error::General(format!("Failed to reset mocks: {e}")))?;
342
343        if !response.status().is_success() {
344            return Err(Error::General(format!(
345                "Failed to reset mocks: HTTP {}",
346                response.status()
347            )));
348        }
349
350        Ok(())
351    }
352}
353
354/// Builder for creating mock configurations with fluent API
355///
356/// This builder provides a WireMock-like fluent API for creating mock configurations
357/// with comprehensive request matching and response configuration.
358///
359/// # Examples
360///
361/// ```rust
362/// use mockforge_sdk::admin::MockConfigBuilder;
363/// use serde_json::json;
364///
365/// // Basic mock
366/// let mock = MockConfigBuilder::new("GET", "/api/users")
367///     .name("Get Users")
368///     .status(200)
369///     .body(json!([{"id": 1, "name": "Alice"}]))
370///     .build();
371///
372/// // Advanced matching with headers and query params
373/// let mock = MockConfigBuilder::new("POST", "/api/users")
374///     .name("Create User")
375///     .with_header("Authorization", "Bearer.*")
376///     .with_query_param("role", "admin")
377///     .with_body_pattern(r#"{"name":".*"}"#)
378///     .status(201)
379///     .body(json!({"id": 123, "created": true}))
380///     .priority(10)
381///     .build();
382/// ```
383pub struct MockConfigBuilder {
384    config: MockConfig,
385}
386
387impl MockConfigBuilder {
388    /// Create a new mock configuration builder
389    ///
390    /// # Arguments
391    /// * `method` - HTTP method (GET, POST, PUT, DELETE, etc.)
392    /// * `path` - URL path pattern (supports path parameters like `/users/{id}`)
393    pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
394        Self {
395            config: MockConfig {
396                id: String::new(),
397                name: String::new(),
398                method: method.into().to_uppercase(),
399                path: path.into(),
400                response: MockResponse {
401                    body: serde_json::json!({}),
402                    headers: None,
403                },
404                enabled: true,
405                latency_ms: None,
406                status_code: None,
407                request_match: None,
408                priority: None,
409                scenario: None,
410                required_scenario_state: None,
411                new_scenario_state: None,
412            },
413        }
414    }
415
416    /// Set the mock ID
417    pub fn id(mut self, id: impl Into<String>) -> Self {
418        self.config.id = id.into();
419        self
420    }
421
422    /// Set the mock name
423    pub fn name(mut self, name: impl Into<String>) -> Self {
424        self.config.name = name.into();
425        self
426    }
427
428    /// Set the response body (supports templating with {{variables}})
429    #[must_use]
430    pub fn body(mut self, body: serde_json::Value) -> Self {
431        self.config.response.body = body;
432        self
433    }
434
435    /// Set the response status code
436    #[must_use]
437    pub const fn status(mut self, status: u16) -> Self {
438        self.config.status_code = Some(status);
439        self
440    }
441
442    /// Set response headers
443    #[must_use]
444    pub fn headers(mut self, headers: HashMap<String, String>) -> Self {
445        self.config.response.headers = Some(headers);
446        self
447    }
448
449    /// Add a single response header
450    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
451        let headers = self.config.response.headers.get_or_insert_with(HashMap::new);
452        headers.insert(key.into(), value.into());
453        self
454    }
455
456    /// Set the latency in milliseconds
457    #[must_use]
458    pub const fn latency_ms(mut self, ms: u64) -> Self {
459        self.config.latency_ms = Some(ms);
460        self
461    }
462
463    /// Enable or disable the mock
464    #[must_use]
465    pub const fn enabled(mut self, enabled: bool) -> Self {
466        self.config.enabled = enabled;
467        self
468    }
469
470    // ========== Request Matching Methods ==========
471
472    /// Require a specific header to be present and match (supports regex patterns)
473    ///
474    /// # Examples
475    /// ```rust
476    /// MockConfigBuilder::new("GET", "/api/users")
477    ///     .with_header("Authorization", "Bearer.*")
478    ///     .with_header("Content-Type", "application/json")
479    /// ```
480    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
481        let match_criteria =
482            self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
483        match_criteria.headers.insert(name.into(), value.into());
484        self
485    }
486
487    /// Require multiple headers to be present and match
488    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
489        let match_criteria =
490            self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
491        match_criteria.headers.extend(headers);
492        self
493    }
494
495    /// Require a specific query parameter to be present and match
496    ///
497    /// # Examples
498    /// ```rust
499    /// MockConfigBuilder::new("GET", "/api/users")
500    ///     .with_query_param("role", "admin")
501    ///     .with_query_param("limit", "10")
502    /// ```
503    pub fn with_query_param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
504        let match_criteria =
505            self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
506        match_criteria.query_params.insert(name.into(), value.into());
507        self
508    }
509
510    /// Require multiple query parameters to be present and match
511    pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
512        let match_criteria =
513            self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
514        match_criteria.query_params.extend(params);
515        self
516    }
517
518    /// Require the request body to match a pattern (supports exact match or regex)
519    ///
520    /// # Examples
521    /// ```rust
522    /// MockConfigBuilder::new("POST", "/api/users")
523    ///     .with_body_pattern(r#"{"name":".*"}"#)  // Regex pattern
524    ///     .with_body_pattern("exact string match")  // Exact match
525    /// ```
526    pub fn with_body_pattern(mut self, pattern: impl Into<String>) -> Self {
527        let match_criteria =
528            self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
529        match_criteria.body_pattern = Some(pattern.into());
530        self
531    }
532
533    /// Require the request body to match a `JSONPath` expression
534    ///
535    /// # Examples
536    /// ```rust
537    /// MockConfigBuilder::new("POST", "/api/users")
538    ///     .with_json_path("$.name")  // Body must have a 'name' field
539    ///     .with_json_path("$.age > 18")  // Body must have age > 18
540    /// ```
541    pub fn with_json_path(mut self, json_path: impl Into<String>) -> Self {
542        let match_criteria =
543            self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
544        match_criteria.json_path = Some(json_path.into());
545        self
546    }
547
548    /// Require the request body to match an `XPath` expression (for XML)
549    ///
550    /// # Examples
551    /// ```rust
552    /// MockConfigBuilder::new("POST", "/api/users")
553    ///     .with_xpath("/users/user[@id='123']")
554    /// ```
555    pub fn with_xpath(mut self, xpath: impl Into<String>) -> Self {
556        let match_criteria =
557            self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
558        match_criteria.xpath = Some(xpath.into());
559        self
560    }
561
562    /// Set a custom matcher expression for advanced matching logic
563    ///
564    /// # Examples
565    /// ```rust
566    /// MockConfigBuilder::new("GET", "/api/users")
567    ///     .with_custom_matcher("headers.content-type == \"application/json\"")
568    ///     .with_custom_matcher("path =~ \"/api/.*\"")
569    /// ```
570    pub fn with_custom_matcher(mut self, expression: impl Into<String>) -> Self {
571        let match_criteria =
572            self.config.request_match.get_or_insert_with(RequestMatchCriteria::default);
573        match_criteria.custom_matcher = Some(expression.into());
574        self
575    }
576
577    // ========== Priority and Scenario Methods ==========
578
579    /// Set the priority for this mock (higher priority mocks are matched first)
580    ///
581    /// Default priority is 0. Higher numbers = higher priority.
582    #[must_use]
583    pub const fn priority(mut self, priority: i32) -> Self {
584        self.config.priority = Some(priority);
585        self
586    }
587
588    /// Set the scenario name for stateful mocking
589    ///
590    /// Scenarios allow you to create stateful mock sequences where the response
591    /// depends on previous requests.
592    pub fn scenario(mut self, scenario: impl Into<String>) -> Self {
593        self.config.scenario = Some(scenario.into());
594        self
595    }
596
597    /// Require a specific scenario state for this mock to be active
598    ///
599    /// This mock will only match if the scenario is in the specified state.
600    pub fn when_scenario_state(mut self, state: impl Into<String>) -> Self {
601        self.config.required_scenario_state = Some(state.into());
602        self
603    }
604
605    /// Set the new scenario state after this mock is matched
606    ///
607    /// After this mock responds, the scenario will transition to this state.
608    pub fn will_set_scenario_state(mut self, state: impl Into<String>) -> Self {
609        self.config.new_scenario_state = Some(state.into());
610        self
611    }
612
613    /// Build the mock configuration
614    #[must_use]
615    pub fn build(self) -> MockConfig {
616        self.config
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn test_mock_config_builder_basic() {
626        let mock = MockConfigBuilder::new("GET", "/api/users")
627            .name("Get Users")
628            .status(200)
629            .body(serde_json::json!([{"id": 1, "name": "Alice"}]))
630            .latency_ms(100)
631            .header("Content-Type", "application/json")
632            .build();
633
634        assert_eq!(mock.method, "GET");
635        assert_eq!(mock.path, "/api/users");
636        assert_eq!(mock.name, "Get Users");
637        assert_eq!(mock.status_code, Some(200));
638        assert_eq!(mock.latency_ms, Some(100));
639        assert!(mock.enabled);
640    }
641
642    #[test]
643    fn test_mock_config_builder_with_matching() {
644        let mut headers = HashMap::new();
645        headers.insert("Authorization".to_string(), "Bearer.*".to_string());
646
647        let mut query_params = HashMap::new();
648        query_params.insert("role".to_string(), "admin".to_string());
649
650        let mock = MockConfigBuilder::new("POST", "/api/users")
651            .name("Create User")
652            .with_headers(headers.clone())
653            .with_query_params(query_params.clone())
654            .with_body_pattern(r#"{"name":".*"}"#)
655            .status(201)
656            .body(serde_json::json!({"id": 123, "created": true}))
657            .priority(10)
658            .build();
659
660        assert_eq!(mock.method, "POST");
661        assert!(mock.request_match.is_some());
662        let match_criteria = mock.request_match.unwrap();
663        assert_eq!(match_criteria.headers.get("Authorization"), Some(&"Bearer.*".to_string()));
664        assert_eq!(match_criteria.query_params.get("role"), Some(&"admin".to_string()));
665        assert_eq!(match_criteria.body_pattern, Some(r#"{"name":".*"}"#.to_string()));
666        assert_eq!(mock.priority, Some(10));
667    }
668
669    #[test]
670    fn test_mock_config_builder_with_scenario() {
671        let mock = MockConfigBuilder::new("GET", "/api/checkout")
672            .name("Checkout Step 1")
673            .scenario("checkout-flow")
674            .when_scenario_state("started")
675            .will_set_scenario_state("payment")
676            .status(200)
677            .body(serde_json::json!({"step": 1}))
678            .build();
679
680        assert_eq!(mock.scenario, Some("checkout-flow".to_string()));
681        assert_eq!(mock.required_scenario_state, Some("started".to_string()));
682        assert_eq!(mock.new_scenario_state, Some("payment".to_string()));
683    }
684
685    #[test]
686    fn test_mock_config_builder_fluent_chaining() {
687        let mock = MockConfigBuilder::new("GET", "/api/users/{id}")
688            .id("user-get-123")
689            .name("Get User by ID")
690            .with_header("Accept", "application/json")
691            .with_query_param("include", "profile")
692            .with_json_path("$.id")
693            .status(200)
694            .body(serde_json::json!({"id": "{{request.path.id}}", "name": "Alice"}))
695            .header("X-Request-ID", "{{uuid}}")
696            .latency_ms(50)
697            .priority(5)
698            .enabled(true)
699            .build();
700
701        assert_eq!(mock.id, "user-get-123");
702        assert_eq!(mock.name, "Get User by ID");
703        assert!(mock.request_match.is_some());
704        let match_criteria = mock.request_match.unwrap();
705        assert!(match_criteria.headers.contains_key("Accept"));
706        assert!(match_criteria.query_params.contains_key("include"));
707        assert_eq!(match_criteria.json_path, Some("$.id".to_string()));
708        assert_eq!(mock.priority, Some(5));
709    }
710}