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 request_delay_ms: Option<u64>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub custom_checks_yaml: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "snake_case")]
63pub enum RunStatus {
64 Pending,
66 Running,
68 Completed,
70 Failed,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ConformanceRun {
77 pub id: Uuid,
79 pub status: RunStatus,
81 pub config: ConformanceRunRequest,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub report: Option<serde_json::Value>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub error: Option<String>,
89 pub checks_done: usize,
91 pub total_checks: usize,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ConformanceRunSummary {
98 pub id: Uuid,
100 pub status: RunStatus,
102 pub checks_done: usize,
104 pub total_checks: usize,
106 pub target_url: String,
108}
109
110impl ConformanceClient {
111 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 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 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 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 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 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 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}