Skip to main content

mockforge_tui/api/
client.rs

1//! HTTP client for the MockForge admin API.
2
3use anyhow::{Context, Result};
4use reqwest::Client;
5use serde::de::DeserializeOwned;
6
7use super::models::*;
8
9/// HTTP client wrapping `reqwest` with base URL and optional auth.
10#[derive(Clone)]
11pub struct MockForgeClient {
12    client: Client,
13    base_url: String,
14    token: Option<String>,
15}
16
17impl MockForgeClient {
18    /// Create a new client pointing at the given admin server.
19    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    /// Base URL for SSE stream connections.
35    pub fn base_url(&self) -> &str {
36        &self.base_url
37    }
38
39    /// Build a GET request with auth header if configured.
40    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    /// Build a POST request with auth header and JSON body.
50    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    /// Send a GET, check for JSON content type, and unwrap the `ApiResponse<T>` envelope.
60    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        // Guard against HTML responses from the SPA fallback (endpoint doesn't exist).
69        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    /// Send a GET, check for JSON content type, and return raw JSON.
91    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    /// POST helper that expects an `ApiResponse<String>` result.
114    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    // ── Tier 1 endpoints ─────────────────────────────────────────────
143
144    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        // Server may return ApiResponse<Vec<RouteInfo>> or {"routes": [...]}
150        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        // Try ApiResponse envelope first
173        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        // Try {"routes": [...]} wrapper
180        if let Ok(wrapper) = serde_json::from_str::<RoutesWrapper>(&body) {
181            return Ok(wrapper.routes);
182        }
183
184        // Try raw array
185        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        // Server may return ApiResponse<ServerInfo> or raw ServerInfo
210        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        // Try ApiResponse envelope first
233        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        // Try raw ServerInfo
240        serde_json::from_str::<ServerInfo>(&body).context("deserialise server-info response")
241    }
242
243    pub async fn get_plugins(&self) -> Result<Vec<PluginInfo>> {
244        // Server returns ApiResponse<{"plugins": [...], "total": N}>
245        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        // Try ApiResponse<Vec<PluginInfo>> first
268        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        // Try ApiResponse<PluginsWrapper>
275        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    // ── Tier 2 endpoints ─────────────────────────────────────────────
301
302    pub async fn get_chaos_status(&self) -> Result<serde_json::Value> {
303        self.get_api("/__mockforge/chaos").await
304    }
305
306    /// Snapshot of chaos fault-injection counters (issue-#79 follow-up).
307    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        // Server returns raw TimeTravelStatus, not ApiResponse-wrapped
349        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    // ── Tier 3 endpoints ─────────────────────────────────────────────
365
366    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    /// Issue #79 round 13 — fetch the unknown-paths feed (requests
394    /// whose path didn't match any route in the loaded spec).
395    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    /// Clear the unknown-paths ring buffer.
419    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    /// Clear the server-side conformance violation buffer.
440    /// Returns the number of entries that were cleared.
441    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        // Server returns {"captures": [...]} not ApiResponse-wrapped
464        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        // Try {"captures": [...]} wrapper first (actual server format)
487        if let Ok(wrapper) = serde_json::from_str::<ContractDiffWrapper>(&body) {
488            return Ok(wrapper.captures);
489        }
490
491        // Try ApiResponse envelope
492        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        // Try raw array
499        serde_json::from_str::<Vec<ContractDiffCapture>>(&body)
500            .context("deserialise contract-diff response")
501    }
502
503    // ── Behavioral cloning / VBR ───────────────────────────────────
504
505    pub async fn get_vbr_status(&self) -> Result<serde_json::Value> {
506        self.get_api("/__mockforge/vbr/status").await
507    }
508
509    // ── Config mutations ─────────────────────────────────────────────
510
511    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    // ── Verification ─────────────────────────────────────────────────
526
527    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    // ── Time travel mutations ────────────────────────────────────────
554
555    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    // ── Chain execution ──────────────────────────────────────────────
564
565    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    // ── Import ───────────────────────────────────────────────────────
593
594    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    // ── Recorder ─────────────────────────────────────────────────────
603
604    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    // ── Workspace activation ──────────────────────────────────────────
618
619    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    // ── World State ──────────────────────────────────────────────────
628
629    pub async fn get_world_state(&self) -> Result<serde_json::Value> {
630        self.get_api("/__mockforge/world-state").await
631    }
632
633    // ── Connectivity check ───────────────────────────────────────────
634
635    /// Quick ping to verify the admin server is reachable.
636    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}