Skip to main content

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