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