Skip to main content

mockforge_sdk/
conformance.rs

1//! Conformance testing API client
2//!
3//! Provides programmatic access to `MockForge`'s conformance testing API for
4//! starting, monitoring, and retrieving `OpenAPI` conformance test results.
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::time::Duration;
15use uuid::Uuid;
16
17/// Conformance testing API client
18pub struct ConformanceClient {
19    base_url: String,
20    client: Client,
21}
22
23/// Request body for starting a conformance run
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25pub struct ConformanceRunRequest {
26    /// Target URL to test against
27    pub target_url: String,
28    /// Inline `OpenAPI` spec JSON/YAML (optional)
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub spec: Option<String>,
31    /// Categories to test (optional filter)
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub categories: Option<Vec<String>>,
34    /// Custom request headers
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub custom_headers: Option<Vec<(String, String)>>,
37    /// API key for security tests
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub api_key: Option<String>,
40    /// Basic auth credentials (user:pass)
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub basic_auth: Option<String>,
43    /// Skip TLS verification
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub skip_tls_verify: Option<bool>,
46    /// API base path prefix
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub base_path: Option<String>,
49    /// Test all operations (not just representative samples)
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub all_operations: Option<bool>,
52    /// Inline YAML custom checks
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub custom_checks_yaml: Option<String>,
55}
56
57/// Conformance run status
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60pub enum RunStatus {
61    /// Run is queued
62    Pending,
63    /// Run is in progress
64    Running,
65    /// Run completed successfully
66    Completed,
67    /// Run failed
68    Failed,
69}
70
71/// A conformance test run
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ConformanceRun {
74    /// Unique run ID
75    pub id: Uuid,
76    /// Current status
77    pub status: RunStatus,
78    /// Configuration used
79    pub config: ConformanceRunRequest,
80    /// Report (available when completed)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub report: Option<serde_json::Value>,
83    /// Error message (available when failed)
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub error: Option<String>,
86    /// Number of checks completed so far
87    pub checks_done: usize,
88    /// Total number of checks
89    pub total_checks: usize,
90}
91
92/// Summary of a conformance run (returned by list endpoint)
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ConformanceRunSummary {
95    /// Unique run ID
96    pub id: Uuid,
97    /// Current status
98    pub status: RunStatus,
99    /// Number of checks completed
100    pub checks_done: usize,
101    /// Total number of checks
102    pub total_checks: usize,
103    /// Target URL being tested
104    pub target_url: String,
105}
106
107impl ConformanceClient {
108    /// Create a new conformance client
109    ///
110    /// The base URL should be the admin API root (e.g., `http://localhost:9080`).
111    pub fn new(base_url: impl Into<String>) -> Self {
112        let mut url = base_url.into();
113        while url.ends_with('/') {
114            url.pop();
115        }
116        Self {
117            base_url: url,
118            client: Client::new(),
119        }
120    }
121
122    /// Start a new conformance test run
123    ///
124    /// Returns the UUID of the newly created run.
125    pub async fn run(&self, config: ConformanceRunRequest) -> Result<Uuid> {
126        let url = format!("{}/api/conformance/run", self.base_url);
127        let response = self
128            .client
129            .post(&url)
130            .json(&config)
131            .send()
132            .await
133            .map_err(|e| Error::General(format!("Failed to start conformance run: {e}")))?;
134
135        if !response.status().is_success() {
136            return Err(Error::General(format!(
137                "Failed to start conformance run: HTTP {}",
138                response.status()
139            )));
140        }
141
142        let body: serde_json::Value = response
143            .json()
144            .await
145            .map_err(|e| Error::General(format!("Failed to parse response: {e}")))?;
146
147        body["id"]
148            .as_str()
149            .and_then(|s| Uuid::parse_str(s).ok())
150            .ok_or_else(|| Error::General("Response missing 'id' field".to_string()))
151    }
152
153    /// Get the status and results of a conformance run
154    pub async fn get_status(&self, id: Uuid) -> Result<ConformanceRun> {
155        let url = format!("{}/api/conformance/run/{}", self.base_url, id);
156        let response = self
157            .client
158            .get(&url)
159            .send()
160            .await
161            .map_err(|e| Error::General(format!("Failed to get conformance run: {e}")))?;
162
163        if response.status() == reqwest::StatusCode::NOT_FOUND {
164            return Err(Error::General(format!("Conformance run not found: {id}")));
165        }
166
167        if !response.status().is_success() {
168            return Err(Error::General(format!(
169                "Failed to get conformance run: HTTP {}",
170                response.status()
171            )));
172        }
173
174        response
175            .json()
176            .await
177            .map_err(|e| Error::General(format!("Failed to parse response: {e}")))
178    }
179
180    /// Get the report for a completed conformance run
181    ///
182    /// Returns the report JSON if the run is completed, or an error if not yet done.
183    pub async fn get_report(&self, id: Uuid) -> Result<serde_json::Value> {
184        let run = self.get_status(id).await?;
185        match run.status {
186            RunStatus::Completed => run
187                .report
188                .ok_or_else(|| Error::General("Run completed but no report available".to_string())),
189            RunStatus::Failed => Err(Error::General(format!(
190                "Conformance run failed: {}",
191                run.error.unwrap_or_else(|| "unknown error".to_string())
192            ))),
193            _ => Err(Error::General(format!(
194                "Conformance run not yet completed (status: {:?})",
195                run.status
196            ))),
197        }
198    }
199
200    /// List all conformance runs
201    pub async fn list_runs(&self) -> Result<Vec<ConformanceRunSummary>> {
202        let url = format!("{}/api/conformance/runs", self.base_url);
203        let response = self
204            .client
205            .get(&url)
206            .send()
207            .await
208            .map_err(|e| Error::General(format!("Failed to list conformance runs: {e}")))?;
209
210        if !response.status().is_success() {
211            return Err(Error::General(format!(
212                "Failed to list conformance runs: HTTP {}",
213                response.status()
214            )));
215        }
216
217        response
218            .json()
219            .await
220            .map_err(|e| Error::General(format!("Failed to parse response: {e}")))
221    }
222
223    /// Delete a completed conformance run
224    pub async fn delete_run(&self, id: Uuid) -> Result<()> {
225        let url = format!("{}/api/conformance/run/{}", self.base_url, id);
226        let response = self
227            .client
228            .delete(&url)
229            .send()
230            .await
231            .map_err(|e| Error::General(format!("Failed to delete conformance run: {e}")))?;
232
233        if response.status() == reqwest::StatusCode::NOT_FOUND {
234            return Err(Error::General(format!("Conformance run not found: {id}")));
235        }
236
237        if response.status() == reqwest::StatusCode::CONFLICT {
238            return Err(Error::General("Cannot delete a running conformance test".to_string()));
239        }
240
241        if !response.status().is_success() {
242            return Err(Error::General(format!(
243                "Failed to delete conformance run: HTTP {}",
244                response.status()
245            )));
246        }
247
248        Ok(())
249    }
250
251    /// Wait for a conformance run to complete, polling at the given interval
252    ///
253    /// Returns the report JSON once the run finishes, or an error if it fails.
254    pub async fn wait_for_completion(
255        &self,
256        id: Uuid,
257        poll_interval: Duration,
258    ) -> Result<serde_json::Value> {
259        loop {
260            let run = self.get_status(id).await?;
261            match run.status {
262                RunStatus::Completed => {
263                    return run.report.ok_or_else(|| {
264                        Error::General("Run completed but no report available".to_string())
265                    });
266                }
267                RunStatus::Failed => {
268                    return Err(Error::General(format!(
269                        "Conformance run failed: {}",
270                        run.error.unwrap_or_else(|| "unknown error".to_string())
271                    )));
272                }
273                _ => {
274                    tokio::time::sleep(poll_interval).await;
275                }
276            }
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_conformance_client_new() {
287        let client = ConformanceClient::new("http://localhost:9080");
288        assert_eq!(client.base_url, "http://localhost:9080");
289    }
290
291    #[test]
292    fn test_conformance_client_strips_trailing_slash() {
293        let client = ConformanceClient::new("http://localhost:9080/");
294        assert_eq!(client.base_url, "http://localhost:9080");
295    }
296
297    #[test]
298    fn test_conformance_run_request_default() {
299        let req = ConformanceRunRequest::default();
300        assert!(req.target_url.is_empty());
301        assert!(req.spec.is_none());
302        assert!(req.categories.is_none());
303    }
304
305    #[test]
306    fn test_run_status_serialization() {
307        let status = RunStatus::Completed;
308        let json = serde_json::to_string(&status).unwrap();
309        assert_eq!(json, "\"completed\"");
310    }
311
312    #[test]
313    fn test_run_status_deserialization() {
314        let status: RunStatus = serde_json::from_str("\"running\"").unwrap();
315        assert_eq!(status, RunStatus::Running);
316    }
317}