mockforge_tui/api/
client.rs1use anyhow::{Context, Result};
4use reqwest::Client;
5use serde::de::DeserializeOwned;
6
7use super::models::*;
8
9#[derive(Clone)]
11pub struct MockForgeClient {
12 client: Client,
13 base_url: String,
14 token: Option<String>,
15}
16
17impl MockForgeClient {
18 pub fn new(base_url: String, token: Option<String>) -> Result<Self> {
20 let client = Client::builder()
21 .timeout(std::time::Duration::from_secs(10))
22 .build()
23 .context("failed to create HTTP client")?;
24
25 let base_url = base_url.trim_end_matches('/').to_string();
26
27 Ok(Self {
28 client,
29 base_url,
30 token,
31 })
32 }
33
34 pub fn base_url(&self) -> &str {
36 &self.base_url
37 }
38
39 fn get(&self, path: &str) -> reqwest::RequestBuilder {
41 let url = format!("{}{path}", self.base_url);
42 let mut req = self.client.get(&url);
43 if let Some(ref token) = self.token {
44 req = req.bearer_auth(token);
45 }
46 req
47 }
48
49 fn post<T: serde::Serialize>(&self, path: &str, body: &T) -> reqwest::RequestBuilder {
51 let url = format!("{}{path}", self.base_url);
52 let mut req = self.client.post(&url).json(body);
53 if let Some(ref token) = self.token {
54 req = req.bearer_auth(token);
55 }
56 req
57 }
58
59 async fn get_api<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
61 let resp = self.get(path).send().await.with_context(|| format!("GET {path}"))?;
62
63 let status = resp.status();
64 if !status.is_success() {
65 anyhow::bail!("HTTP {status} from {path}");
66 }
67
68 let ct = resp
70 .headers()
71 .get(reqwest::header::CONTENT_TYPE)
72 .and_then(|v| v.to_str().ok())
73 .unwrap_or("");
74 if ct.contains("text/html") {
75 anyhow::bail!("endpoint {path} not available (got HTML)");
76 }
77
78 let body = resp.text().await.with_context(|| format!("read body from {path}"))?;
79
80 let envelope: ApiResponse<T> = serde_json::from_str(&body)
81 .with_context(|| format!("deserialise response from {path}"))?;
82
83 if envelope.success {
84 envelope.data.context("API returned success but no data")
85 } else {
86 anyhow::bail!("API error: {}", envelope.error.unwrap_or_else(|| "unknown".into()))
87 }
88 }
89
90 async fn get_raw<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
92 let resp = self.get(path).send().await.with_context(|| format!("GET {path}"))?;
93
94 let status = resp.status();
95 if !status.is_success() {
96 anyhow::bail!("HTTP {status} from {path}");
97 }
98
99 let ct = resp
100 .headers()
101 .get(reqwest::header::CONTENT_TYPE)
102 .and_then(|v| v.to_str().ok())
103 .unwrap_or("");
104 if ct.contains("text/html") {
105 anyhow::bail!("endpoint {path} not available (got HTML)");
106 }
107
108 let body = resp.text().await.with_context(|| format!("read body from {path}"))?;
109
110 serde_json::from_str(&body).with_context(|| format!("deserialise response from {path}"))
111 }
112
113 async fn post_api(&self, path: &str, body: &serde_json::Value) -> Result<String> {
115 let resp = self.post(path, body).send().await.with_context(|| format!("POST {path}"))?;
116
117 let status = resp.status();
118 if !status.is_success() {
119 anyhow::bail!("HTTP {status} from {path}");
120 }
121
122 let ct = resp
123 .headers()
124 .get(reqwest::header::CONTENT_TYPE)
125 .and_then(|v| v.to_str().ok())
126 .unwrap_or("");
127 if ct.contains("text/html") {
128 anyhow::bail!("endpoint {path} not available");
129 }
130
131 let body_text = resp.text().await.context("read POST response body")?;
132 let envelope: ApiResponse<String> = serde_json::from_str(&body_text)
133 .with_context(|| format!("deserialise response from {path}"))?;
134
135 if envelope.success {
136 Ok(envelope.data.unwrap_or_default())
137 } else {
138 anyhow::bail!("API error: {}", envelope.error.unwrap_or_else(|| "unknown".into()))
139 }
140 }
141
142 pub async fn get_dashboard(&self) -> Result<DashboardData> {
145 self.get_api("/__mockforge/dashboard").await
146 }
147
148 pub async fn get_routes(&self) -> Result<Vec<RouteInfo>> {
149 let resp = self
151 .get("/__mockforge/routes")
152 .send()
153 .await
154 .context("GET /__mockforge/routes")?;
155
156 let status = resp.status();
157 if !status.is_success() {
158 anyhow::bail!("HTTP {status} from /__mockforge/routes");
159 }
160
161 let ct = resp
162 .headers()
163 .get(reqwest::header::CONTENT_TYPE)
164 .and_then(|v| v.to_str().ok())
165 .unwrap_or("");
166 if ct.contains("text/html") {
167 anyhow::bail!("endpoint /__mockforge/routes not available");
168 }
169
170 let body = resp.text().await.context("read routes response")?;
171
172 if let Ok(envelope) = serde_json::from_str::<ApiResponse<Vec<RouteInfo>>>(&body) {
174 if envelope.success {
175 return envelope.data.context("routes: no data");
176 }
177 }
178
179 if let Ok(wrapper) = serde_json::from_str::<RoutesWrapper>(&body) {
181 return Ok(wrapper.routes);
182 }
183
184 serde_json::from_str::<Vec<RouteInfo>>(&body).context("deserialise routes response")
186 }
187
188 pub async fn get_logs(&self, limit: Option<u32>) -> Result<Vec<RequestLog>> {
189 let path = match limit {
190 Some(n) => format!("/__mockforge/logs?limit={n}"),
191 None => "/__mockforge/logs".into(),
192 };
193 self.get_api(&path).await
194 }
195
196 pub async fn get_metrics(&self) -> Result<MetricsData> {
197 self.get_api("/__mockforge/metrics").await
198 }
199
200 pub async fn get_config(&self) -> Result<ConfigState> {
201 self.get_api("/__mockforge/config").await
202 }
203
204 pub async fn get_health(&self) -> Result<HealthCheck> {
205 self.get_raw("/__mockforge/health").await
206 }
207
208 pub async fn get_server_info(&self) -> Result<ServerInfo> {
209 let resp = self
211 .get("/__mockforge/server-info")
212 .send()
213 .await
214 .context("GET /__mockforge/server-info")?;
215
216 let status = resp.status();
217 if !status.is_success() {
218 anyhow::bail!("HTTP {status} from /__mockforge/server-info");
219 }
220
221 let ct = resp
222 .headers()
223 .get(reqwest::header::CONTENT_TYPE)
224 .and_then(|v| v.to_str().ok())
225 .unwrap_or("");
226 if ct.contains("text/html") {
227 anyhow::bail!("endpoint /__mockforge/server-info not available");
228 }
229
230 let body = resp.text().await.context("read server-info response")?;
231
232 if let Ok(envelope) = serde_json::from_str::<ApiResponse<ServerInfo>>(&body) {
234 if envelope.success {
235 return envelope.data.context("server-info: no data");
236 }
237 }
238
239 serde_json::from_str::<ServerInfo>(&body).context("deserialise server-info response")
241 }
242
243 pub async fn get_plugins(&self) -> Result<Vec<PluginInfo>> {
244 let resp = self
246 .get("/__mockforge/plugins")
247 .send()
248 .await
249 .context("GET /__mockforge/plugins")?;
250
251 let status = resp.status();
252 if !status.is_success() {
253 anyhow::bail!("HTTP {status} from /__mockforge/plugins");
254 }
255
256 let ct = resp
257 .headers()
258 .get(reqwest::header::CONTENT_TYPE)
259 .and_then(|v| v.to_str().ok())
260 .unwrap_or("");
261 if ct.contains("text/html") {
262 anyhow::bail!("endpoint /__mockforge/plugins not available");
263 }
264
265 let body = resp.text().await.context("read plugins response")?;
266
267 if let Ok(envelope) = serde_json::from_str::<ApiResponse<Vec<PluginInfo>>>(&body) {
269 if envelope.success {
270 return envelope.data.context("plugins: no data");
271 }
272 }
273
274 if let Ok(envelope) = serde_json::from_str::<ApiResponse<PluginsWrapper>>(&body) {
276 if envelope.success {
277 return Ok(envelope.data.map(|w| w.plugins).unwrap_or_default());
278 }
279 }
280
281 Ok(Vec::new())
282 }
283
284 pub async fn get_fixtures(&self) -> Result<Vec<FixtureInfo>> {
285 self.get_api("/__mockforge/fixtures").await
286 }
287
288 pub async fn get_smoke_tests(&self) -> Result<Vec<SmokeTestResult>> {
289 self.get_api("/__mockforge/smoke").await
290 }
291
292 pub async fn run_smoke_tests(&self) -> Result<Vec<SmokeTestResult>> {
293 self.get_api("/__mockforge/smoke/run").await
294 }
295
296 pub async fn get_workspaces(&self) -> Result<Vec<WorkspaceInfo>> {
297 self.get_api("/__mockforge/workspaces").await
298 }
299
300 pub async fn get_chaos_status(&self) -> Result<serde_json::Value> {
303 self.get_api("/__mockforge/chaos").await
304 }
305
306 pub async fn get_chaos_stats(&self) -> Result<serde_json::Value> {
308 self.get_api("/__mockforge/chaos/stats").await
309 }
310
311 pub async fn toggle_chaos(&self, enabled: bool) -> Result<String> {
312 self.post_api("/__mockforge/chaos/toggle", &serde_json::json!({ "enabled": enabled }))
313 .await
314 }
315
316 pub async fn get_chaos_scenarios(&self) -> Result<serde_json::Value> {
317 self.get_api("/__mockforge/chaos/scenarios/predefined").await
318 }
319
320 pub async fn start_chaos_scenario(&self, name: &str) -> Result<String> {
321 self.post_api(&format!("/__mockforge/chaos/scenarios/{name}"), &serde_json::json!({}))
322 .await
323 }
324
325 pub async fn stop_chaos_scenario(&self, name: &str) -> Result<String> {
326 let url = format!("{}/__mockforge/chaos/scenarios/{name}", self.base_url);
327 let resp = self.client.delete(&url).send().await.context("DELETE chaos/scenarios stop")?;
328
329 let status = resp.status();
330 if !status.is_success() {
331 anyhow::bail!("HTTP {status} from chaos stop");
332 }
333
334 let body = resp.text().await.context("read chaos stop response")?;
335 let envelope: ApiResponse<String> =
336 serde_json::from_str(&body).context("deserialise chaos stop response")?;
337 if envelope.success {
338 Ok(envelope.data.unwrap_or_default())
339 } else {
340 anyhow::bail!(
341 "stop scenario failed: {}",
342 envelope.error.unwrap_or_else(|| "unknown".into())
343 )
344 }
345 }
346
347 pub async fn get_time_travel_status(&self) -> Result<TimeTravelStatus> {
348 self.get_raw("/__mockforge/time-travel/status").await
350 }
351
352 pub async fn get_chains(&self) -> Result<Vec<ChainInfo>> {
353 self.get_api("/__mockforge/chains").await
354 }
355
356 pub async fn get_audit_logs(&self) -> Result<Vec<AuditEntry>> {
357 self.get_api("/__mockforge/audit/logs").await
358 }
359
360 pub async fn get_analytics_summary(&self) -> Result<AnalyticsSummary> {
361 self.get_api("/__mockforge/analytics/summary").await
362 }
363
364 pub async fn get_federation_peers(&self) -> Result<Vec<FederationPeer>> {
367 self.get_api("/__mockforge/federation/peers").await
368 }
369
370 pub async fn get_conformance_violations(&self) -> Result<ConformanceViolationsResponse> {
371 let resp = self
372 .get("/__mockforge/api/conformance/violations")
373 .send()
374 .await
375 .context("GET /__mockforge/api/conformance/violations")?;
376 let status = resp.status();
377 if !status.is_success() {
378 anyhow::bail!("HTTP {status} from conformance/violations");
379 }
380 let ct = resp
381 .headers()
382 .get(reqwest::header::CONTENT_TYPE)
383 .and_then(|v| v.to_str().ok())
384 .unwrap_or("");
385 if ct.contains("text/html") {
386 anyhow::bail!("endpoint conformance/violations not available");
387 }
388 let body = resp.text().await.context("read conformance response")?;
389 serde_json::from_str::<ConformanceViolationsResponse>(&body)
390 .context("deserialise conformance response")
391 }
392
393 pub async fn get_unknown_paths(&self) -> Result<UnknownPathsResponse> {
396 let resp = self
397 .get("/__mockforge/api/conformance/unknown-paths")
398 .send()
399 .await
400 .context("GET /__mockforge/api/conformance/unknown-paths")?;
401 let status = resp.status();
402 if !status.is_success() {
403 anyhow::bail!("HTTP {status} from unknown-paths");
404 }
405 let ct = resp
406 .headers()
407 .get(reqwest::header::CONTENT_TYPE)
408 .and_then(|v| v.to_str().ok())
409 .unwrap_or("");
410 if ct.contains("text/html") {
411 anyhow::bail!("endpoint unknown-paths not available");
412 }
413 let body = resp.text().await.context("read unknown-paths response")?;
414 serde_json::from_str::<UnknownPathsResponse>(&body)
415 .context("deserialise unknown-paths response")
416 }
417
418 pub async fn clear_unknown_paths(&self) -> Result<usize> {
420 let url = format!("{}/__mockforge/api/conformance/unknown-paths", self.base_url);
421 let mut req = self.client.delete(&url);
422 if let Some(ref token) = self.token {
423 req = req.bearer_auth(token);
424 }
425 let resp = req.send().await.context("DELETE /__mockforge/api/conformance/unknown-paths")?;
426 if !resp.status().is_success() {
427 anyhow::bail!("HTTP {} from DELETE unknown-paths", resp.status());
428 }
429 #[derive(serde::Deserialize)]
430 struct Cleared {
431 cleared: usize,
432 }
433 let body = resp.text().await.context("read clear-unknown-paths response")?;
434 serde_json::from_str::<Cleared>(&body)
435 .map(|c| c.cleared)
436 .context("deserialise clear-unknown-paths response")
437 }
438
439 pub async fn clear_conformance_violations(&self) -> Result<usize> {
442 let url = format!("{}/__mockforge/api/conformance/violations", self.base_url);
443 let mut req = self.client.delete(&url);
444 if let Some(ref token) = self.token {
445 req = req.bearer_auth(token);
446 }
447 let resp = req.send().await.context("DELETE /__mockforge/api/conformance/violations")?;
448 let status = resp.status();
449 if !status.is_success() {
450 anyhow::bail!("HTTP {status} from DELETE conformance/violations");
451 }
452 #[derive(serde::Deserialize)]
453 struct Cleared {
454 cleared: usize,
455 }
456 let body = resp.text().await.context("read clear-conformance response")?;
457 serde_json::from_str::<Cleared>(&body)
458 .map(|c| c.cleared)
459 .context("deserialise clear-conformance response")
460 }
461
462 pub async fn get_contract_diff_captures(&self) -> Result<Vec<ContractDiffCapture>> {
463 let resp = self
465 .get("/__mockforge/contract-diff/captures")
466 .send()
467 .await
468 .context("GET /__mockforge/contract-diff/captures")?;
469
470 let status = resp.status();
471 if !status.is_success() {
472 anyhow::bail!("HTTP {status} from contract-diff/captures");
473 }
474
475 let ct = resp
476 .headers()
477 .get(reqwest::header::CONTENT_TYPE)
478 .and_then(|v| v.to_str().ok())
479 .unwrap_or("");
480 if ct.contains("text/html") {
481 anyhow::bail!("endpoint contract-diff/captures not available");
482 }
483
484 let body = resp.text().await.context("read contract-diff response")?;
485
486 if let Ok(wrapper) = serde_json::from_str::<ContractDiffWrapper>(&body) {
488 return Ok(wrapper.captures);
489 }
490
491 if let Ok(envelope) = serde_json::from_str::<ApiResponse<Vec<ContractDiffCapture>>>(&body) {
493 if envelope.success {
494 return envelope.data.context("contract-diff: no data");
495 }
496 }
497
498 serde_json::from_str::<Vec<ContractDiffCapture>>(&body)
500 .context("deserialise contract-diff response")
501 }
502
503 pub async fn get_vbr_status(&self) -> Result<serde_json::Value> {
506 self.get_api("/__mockforge/vbr/status").await
507 }
508
509 pub async fn update_latency(&self, config: &LatencyConfig) -> Result<String> {
512 self.post_api("/__mockforge/config/latency", &serde_json::to_value(config)?)
513 .await
514 }
515
516 pub async fn update_faults(&self, config: &FaultConfig) -> Result<String> {
517 self.post_api("/__mockforge/config/faults", &serde_json::to_value(config)?)
518 .await
519 }
520
521 pub async fn update_proxy(&self, config: &ProxyConfig) -> Result<String> {
522 self.post_api("/__mockforge/config/proxy", &serde_json::to_value(config)?).await
523 }
524
525 pub async fn verify(&self, query: &serde_json::Value) -> Result<VerificationResult> {
528 let resp = self
529 .post("/__mockforge/verification/verify", query)
530 .send()
531 .await
532 .context("POST verification/verify")?;
533
534 let status = resp.status();
535 if !status.is_success() {
536 anyhow::bail!("HTTP {status} from verification/verify");
537 }
538
539 let body = resp.text().await.context("read verification response")?;
540 let envelope: ApiResponse<VerificationResult> =
541 serde_json::from_str(&body).context("deserialise verification response")?;
542
543 if envelope.success {
544 envelope.data.context("verification returned no data")
545 } else {
546 anyhow::bail!(
547 "verification failed: {}",
548 envelope.error.unwrap_or_else(|| "unknown".into())
549 )
550 }
551 }
552
553 pub async fn enable_time_travel(&self) -> Result<String> {
556 self.post_api("/__mockforge/time-travel/enable", &serde_json::json!({})).await
557 }
558
559 pub async fn disable_time_travel(&self) -> Result<String> {
560 self.post_api("/__mockforge/time-travel/disable", &serde_json::json!({})).await
561 }
562
563 pub async fn execute_chain(&self, id: &str) -> Result<serde_json::Value> {
566 let path = format!("/__mockforge/chains/{id}/execute");
567 let resp = self
568 .post(&path, &serde_json::json!({}))
569 .send()
570 .await
571 .with_context(|| format!("POST {path}"))?;
572
573 let status = resp.status();
574 if !status.is_success() {
575 anyhow::bail!("HTTP {status} from {path}");
576 }
577
578 let body = resp.text().await.context("read chain execution response")?;
579 let envelope: ApiResponse<serde_json::Value> =
580 serde_json::from_str(&body).context("deserialise chain execution response")?;
581
582 if envelope.success {
583 envelope.data.context("chain execution returned no data")
584 } else {
585 anyhow::bail!(
586 "chain execution failed: {}",
587 envelope.error.unwrap_or_else(|| "unknown".into())
588 )
589 }
590 }
591
592 pub async fn get_import_history(&self) -> Result<serde_json::Value> {
595 self.get_api("/__mockforge/import/history").await
596 }
597
598 pub async fn clear_import_history(&self) -> Result<String> {
599 self.post_api("/__mockforge/import/history/clear", &serde_json::json!({})).await
600 }
601
602 pub async fn get_recorder_status(&self) -> Result<serde_json::Value> {
605 self.get_api("/__mockforge/recorder/status").await
606 }
607
608 pub async fn toggle_recorder(&self, enable: bool) -> Result<String> {
609 let path = if enable {
610 "/__mockforge/recorder/start"
611 } else {
612 "/__mockforge/recorder/stop"
613 };
614 self.post_api(path, &serde_json::json!({})).await
615 }
616
617 pub async fn activate_workspace(&self, workspace_id: &str) -> Result<String> {
620 self.post_api(
621 &format!("/__mockforge/workspaces/{workspace_id}/activate"),
622 &serde_json::json!({}),
623 )
624 .await
625 }
626
627 pub async fn get_world_state(&self) -> Result<serde_json::Value> {
630 self.get_api("/__mockforge/world-state").await
631 }
632
633 pub async fn ping(&self) -> bool {
637 self.get("/__mockforge/health")
638 .timeout(std::time::Duration::from_secs(3))
639 .send()
640 .await
641 .is_ok()
642 }
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648
649 #[test]
650 fn client_strips_trailing_slash() {
651 let client = MockForgeClient::new("http://localhost:9080/".into(), None).unwrap();
652 assert_eq!(client.base_url(), "http://localhost:9080");
653 }
654
655 #[test]
656 fn client_preserves_clean_url() {
657 let client = MockForgeClient::new("http://localhost:9080".into(), None).unwrap();
658 assert_eq!(client.base_url(), "http://localhost:9080");
659 }
660}