1#![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
16pub struct AdminClient {
18 base_url: String,
19 client: Client,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct MockConfig {
25 #[serde(skip_serializing_if = "String::is_empty")]
27 pub id: String,
28 pub name: String,
30 pub method: String,
32 pub path: String,
34 pub response: MockResponse,
36 #[serde(default = "default_true")]
38 pub enabled: bool,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub latency_ms: Option<u64>,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub status_code: Option<u16>,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub request_match: Option<RequestMatchCriteria>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub priority: Option<i32>,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub scenario: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub required_scenario_state: Option<String>,
57 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct MockResponse {
69 pub body: serde_json::Value,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub headers: Option<HashMap<String, String>>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct RequestMatchCriteria {
79 #[serde(skip_serializing_if = "HashMap::is_empty")]
81 pub headers: HashMap<String, String>,
82 #[serde(skip_serializing_if = "HashMap::is_empty")]
84 pub query_params: HashMap<String, String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub body_pattern: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub json_path: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub xpath: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub custom_matcher: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ServerStats {
102 pub uptime_seconds: u64,
104 pub total_requests: u64,
106 pub active_mocks: usize,
108 pub enabled_mocks: usize,
110 pub registered_routes: usize,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ServerConfig {
117 pub version: String,
119 pub port: u16,
121 pub has_openapi_spec: bool,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub spec_path: Option<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct MockList {
131 pub mocks: Vec<MockConfig>,
133 pub total: usize,
135 pub enabled: usize,
137}
138
139impl AdminClient {
140 pub fn new(base_url: impl Into<String>) -> Self {
155 let mut url = base_url.into();
156
157 while url.ends_with('/') {
159 url.pop();
160 }
161
162 Self {
163 base_url: url,
164 client: Client::new(),
165 }
166 }
167
168 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 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 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 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 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 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 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 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
359pub struct MockConfigBuilder {
389 config: MockConfig,
390}
391
392impl MockConfigBuilder {
393 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 pub fn id(mut self, id: impl Into<String>) -> Self {
423 self.config.id = id.into();
424 self
425 }
426
427 pub fn name(mut self, name: impl Into<String>) -> Self {
429 self.config.name = name.into();
430 self
431 }
432
433 #[must_use]
435 pub fn body(mut self, body: serde_json::Value) -> Self {
436 self.config.response.body = body;
437 self
438 }
439
440 #[must_use]
442 pub const fn status(mut self, status: u16) -> Self {
443 self.config.status_code = Some(status);
444 self
445 }
446
447 #[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 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 #[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 #[must_use]
470 pub const fn enabled(mut self, enabled: bool) -> Self {
471 self.config.enabled = enabled;
472 self
473 }
474
475 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 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 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 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 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 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 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 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 #[must_use]
600 pub const fn priority(mut self, priority: i32) -> Self {
601 self.config.priority = Some(priority);
602 self
603 }
604
605 pub fn scenario(mut self, scenario: impl Into<String>) -> Self {
610 self.config.scenario = Some(scenario.into());
611 self
612 }
613
614 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 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 #[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}