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::time::Duration;
15use uuid::Uuid;
16
17pub struct ConformanceClient {
19 base_url: String,
20 client: Client,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25pub struct ConformanceRunRequest {
26 pub target_url: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub spec: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub categories: Option<Vec<String>>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub custom_headers: Option<Vec<(String, String)>>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub api_key: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub basic_auth: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub skip_tls_verify: Option<bool>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub base_path: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub all_operations: Option<bool>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub custom_checks_yaml: Option<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60pub enum RunStatus {
61 Pending,
63 Running,
65 Completed,
67 Failed,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ConformanceRun {
74 pub id: Uuid,
76 pub status: RunStatus,
78 pub config: ConformanceRunRequest,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub report: Option<serde_json::Value>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub error: Option<String>,
86 pub checks_done: usize,
88 pub total_checks: usize,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ConformanceRunSummary {
95 pub id: Uuid,
97 pub status: RunStatus,
99 pub checks_done: usize,
101 pub total_checks: usize,
103 pub target_url: String,
105}
106
107impl ConformanceClient {
108 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 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 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 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 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 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 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}