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}
41
42fn default_true() -> bool {
43    true
44}
45
46/// Mock response configuration
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct MockResponse {
49    /// Response body (supports JSON values and templates)
50    pub body: serde_json::Value,
51    /// Optional HTTP headers to include in the response
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub headers: Option<HashMap<String, String>>,
54}
55
56/// Server statistics
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ServerStats {
59    /// Server uptime in seconds
60    pub uptime_seconds: u64,
61    /// Total number of requests served
62    pub total_requests: u64,
63    /// Number of registered mocks (active and inactive)
64    pub active_mocks: usize,
65    /// Number of currently enabled mocks
66    pub enabled_mocks: usize,
67    /// Total number of registered routes
68    pub registered_routes: usize,
69}
70
71/// Server configuration info
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ServerConfig {
74    /// MockForge version
75    pub version: String,
76    /// HTTP port the server is running on
77    pub port: u16,
78    /// Whether an OpenAPI spec is loaded
79    pub has_openapi_spec: bool,
80    /// Path to the OpenAPI spec file (if loaded)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub spec_path: Option<String>,
83}
84
85/// List of mocks with metadata
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct MockList {
88    /// List of mock configurations
89    pub mocks: Vec<MockConfig>,
90    /// Total number of mocks
91    pub total: usize,
92    /// Number of enabled mocks
93    pub enabled: usize,
94}
95
96impl AdminClient {
97    /// Create a new admin client
98    ///
99    /// The base URL should be the root URL of the MockForge server
100    /// (e.g., "http://localhost:3000"). Trailing slashes are automatically removed.
101    ///
102    /// # Examples
103    ///
104    /// ```rust
105    /// use mockforge_sdk::AdminClient;
106    ///
107    /// let client = AdminClient::new("http://localhost:3000");
108    /// // Also works with trailing slash:
109    /// let client = AdminClient::new("http://localhost:3000/");
110    /// ```
111    pub fn new(base_url: impl Into<String>) -> Self {
112        let mut url = base_url.into();
113
114        // Normalize URL: remove trailing slashes
115        while url.ends_with('/') {
116            url.pop();
117        }
118
119        Self {
120            base_url: url,
121            client: Client::new(),
122        }
123    }
124
125    /// List all mocks
126    pub async fn list_mocks(&self) -> Result<MockList> {
127        let url = format!("{}/api/mocks", self.base_url);
128        let response = self
129            .client
130            .get(&url)
131            .send()
132            .await
133            .map_err(|e| Error::General(format!("Failed to list mocks: {}", e)))?;
134
135        if !response.status().is_success() {
136            return Err(Error::General(format!(
137                "Failed to list mocks: HTTP {}",
138                response.status()
139            )));
140        }
141
142        response
143            .json()
144            .await
145            .map_err(|e| Error::General(format!("Failed to parse response: {}", e)))
146    }
147
148    /// Get a specific mock by ID
149    pub async fn get_mock(&self, id: &str) -> Result<MockConfig> {
150        let url = format!("{}/api/mocks/{}", self.base_url, id);
151        let response = self
152            .client
153            .get(&url)
154            .send()
155            .await
156            .map_err(|e| Error::General(format!("Failed to get mock: {}", e)))?;
157
158        if response.status() == reqwest::StatusCode::NOT_FOUND {
159            return Err(Error::General(format!("Mock not found: {}", id)));
160        }
161
162        if !response.status().is_success() {
163            return Err(Error::General(format!("Failed to get mock: HTTP {}", response.status())));
164        }
165
166        response
167            .json()
168            .await
169            .map_err(|e| Error::General(format!("Failed to parse response: {}", e)))
170    }
171
172    /// Create a new mock
173    pub async fn create_mock(&self, mock: MockConfig) -> Result<MockConfig> {
174        let url = format!("{}/api/mocks", self.base_url);
175        let response = self
176            .client
177            .post(&url)
178            .json(&mock)
179            .send()
180            .await
181            .map_err(|e| Error::General(format!("Failed to create mock: {}", e)))?;
182
183        if response.status() == reqwest::StatusCode::CONFLICT {
184            return Err(Error::General(format!("Mock with ID {} already exists", mock.id)));
185        }
186
187        if !response.status().is_success() {
188            return Err(Error::General(format!(
189                "Failed to create mock: HTTP {}",
190                response.status()
191            )));
192        }
193
194        response
195            .json()
196            .await
197            .map_err(|e| Error::General(format!("Failed to parse response: {}", e)))
198    }
199
200    /// Update an existing mock
201    pub async fn update_mock(&self, id: &str, mock: MockConfig) -> Result<MockConfig> {
202        let url = format!("{}/api/mocks/{}", self.base_url, id);
203        let response = self
204            .client
205            .put(&url)
206            .json(&mock)
207            .send()
208            .await
209            .map_err(|e| Error::General(format!("Failed to update mock: {}", e)))?;
210
211        if response.status() == reqwest::StatusCode::NOT_FOUND {
212            return Err(Error::General(format!("Mock not found: {}", id)));
213        }
214
215        if !response.status().is_success() {
216            return Err(Error::General(format!(
217                "Failed to update mock: HTTP {}",
218                response.status()
219            )));
220        }
221
222        response
223            .json()
224            .await
225            .map_err(|e| Error::General(format!("Failed to parse response: {}", e)))
226    }
227
228    /// Delete a mock
229    pub async fn delete_mock(&self, id: &str) -> Result<()> {
230        let url = format!("{}/api/mocks/{}", self.base_url, id);
231        let response = self
232            .client
233            .delete(&url)
234            .send()
235            .await
236            .map_err(|e| Error::General(format!("Failed to delete mock: {}", e)))?;
237
238        if response.status() == reqwest::StatusCode::NOT_FOUND {
239            return Err(Error::General(format!("Mock not found: {}", id)));
240        }
241
242        if !response.status().is_success() {
243            return Err(Error::General(format!(
244                "Failed to delete mock: HTTP {}",
245                response.status()
246            )));
247        }
248
249        Ok(())
250    }
251
252    /// Get server statistics
253    pub async fn get_stats(&self) -> Result<ServerStats> {
254        let url = format!("{}/api/stats", self.base_url);
255        let response = self
256            .client
257            .get(&url)
258            .send()
259            .await
260            .map_err(|e| Error::General(format!("Failed to get stats: {}", e)))?;
261
262        if !response.status().is_success() {
263            return Err(Error::General(format!("Failed to get stats: HTTP {}", response.status())));
264        }
265
266        response
267            .json()
268            .await
269            .map_err(|e| Error::General(format!("Failed to parse response: {}", e)))
270    }
271
272    /// Get server configuration
273    pub async fn get_config(&self) -> Result<ServerConfig> {
274        let url = format!("{}/api/config", self.base_url);
275        let response = self
276            .client
277            .get(&url)
278            .send()
279            .await
280            .map_err(|e| Error::General(format!("Failed to get config: {}", e)))?;
281
282        if !response.status().is_success() {
283            return Err(Error::General(format!(
284                "Failed to get config: HTTP {}",
285                response.status()
286            )));
287        }
288
289        response
290            .json()
291            .await
292            .map_err(|e| Error::General(format!("Failed to parse response: {}", e)))
293    }
294
295    /// Reset all mocks to initial state
296    pub async fn reset(&self) -> Result<()> {
297        let url = format!("{}/api/reset", self.base_url);
298        let response = self
299            .client
300            .post(&url)
301            .send()
302            .await
303            .map_err(|e| Error::General(format!("Failed to reset mocks: {}", e)))?;
304
305        if !response.status().is_success() {
306            return Err(Error::General(format!(
307                "Failed to reset mocks: HTTP {}",
308                response.status()
309            )));
310        }
311
312        Ok(())
313    }
314}
315
316/// Builder for creating mock configurations
317pub struct MockConfigBuilder {
318    config: MockConfig,
319}
320
321impl MockConfigBuilder {
322    /// Create a new mock configuration builder
323    pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
324        Self {
325            config: MockConfig {
326                id: String::new(),
327                name: String::new(),
328                method: method.into().to_uppercase(),
329                path: path.into(),
330                response: MockResponse {
331                    body: serde_json::json!({}),
332                    headers: None,
333                },
334                enabled: true,
335                latency_ms: None,
336                status_code: None,
337            },
338        }
339    }
340
341    /// Set the mock ID
342    pub fn id(mut self, id: impl Into<String>) -> Self {
343        self.config.id = id.into();
344        self
345    }
346
347    /// Set the mock name
348    pub fn name(mut self, name: impl Into<String>) -> Self {
349        self.config.name = name.into();
350        self
351    }
352
353    /// Set the response body
354    pub fn body(mut self, body: serde_json::Value) -> Self {
355        self.config.response.body = body;
356        self
357    }
358
359    /// Set the response status code
360    pub fn status(mut self, status: u16) -> Self {
361        self.config.status_code = Some(status);
362        self
363    }
364
365    /// Set response headers
366    pub fn headers(mut self, headers: HashMap<String, String>) -> Self {
367        self.config.response.headers = Some(headers);
368        self
369    }
370
371    /// Add a single response header
372    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
373        let headers = self.config.response.headers.get_or_insert_with(HashMap::new);
374        headers.insert(key.into(), value.into());
375        self
376    }
377
378    /// Set the latency in milliseconds
379    pub fn latency_ms(mut self, ms: u64) -> Self {
380        self.config.latency_ms = Some(ms);
381        self
382    }
383
384    /// Enable or disable the mock
385    pub fn enabled(mut self, enabled: bool) -> Self {
386        self.config.enabled = enabled;
387        self
388    }
389
390    /// Build the mock configuration
391    pub fn build(self) -> MockConfig {
392        self.config
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_mock_config_builder() {
402        let mock = MockConfigBuilder::new("GET", "/api/users")
403            .name("Get Users")
404            .status(200)
405            .body(serde_json::json!([{"id": 1, "name": "Alice"}]))
406            .latency_ms(100)
407            .header("Content-Type", "application/json")
408            .build();
409
410        assert_eq!(mock.method, "GET");
411        assert_eq!(mock.path, "/api/users");
412        assert_eq!(mock.name, "Get Users");
413        assert_eq!(mock.status_code, Some(200));
414        assert_eq!(mock.latency_ms, Some(100));
415        assert!(mock.enabled);
416    }
417}