Skip to main content

mockforge_bench/conformance/
executor.rs

1//! Native Rust conformance test executor
2//!
3//! Replaces the k6-based execution path with direct HTTP requests via reqwest,
4//! producing the same `ConformanceReport` output. This removes the k6 binary
5//! dependency and enables API/SDK integration.
6
7use super::custom::{CustomCheck, CustomConformanceConfig};
8use super::generator::ConformanceConfig;
9use super::report::{ConformanceReport, FailureDetail, FailureRequest, FailureResponse};
10use super::spec::ConformanceFeature;
11use super::spec_driven::{AnnotatedOperation, ApiKeyLocation, SecuritySchemeInfo};
12use crate::error::{BenchError, Result};
13use reqwest::{Client, Method};
14use std::collections::{HashMap, HashSet};
15use std::time::Duration;
16use tokio::sync::mpsc;
17
18/// A single conformance check to execute
19#[derive(Debug, Clone)]
20pub struct ConformanceCheck {
21    /// Check name (e.g., "param:path:string" or "param:path:string:/users/{id}")
22    pub name: String,
23    /// HTTP method
24    pub method: Method,
25    /// Relative path (appended to base_url)
26    pub path: String,
27    /// Request headers
28    pub headers: Vec<(String, String)>,
29    /// Optional request body
30    pub body: Option<CheckBody>,
31    /// How to validate the response
32    pub validation: CheckValidation,
33}
34
35/// Request body variants
36#[derive(Debug, Clone)]
37pub enum CheckBody {
38    /// JSON body
39    Json(serde_json::Value),
40    /// Form-urlencoded body
41    FormUrlencoded(Vec<(String, String)>),
42    /// Raw string body with content type
43    Raw {
44        content: String,
45        content_type: String,
46    },
47}
48
49/// How to validate a conformance check response
50#[derive(Debug, Clone)]
51pub enum CheckValidation {
52    /// status >= min && status < max_exclusive
53    StatusRange { min: u16, max_exclusive: u16 },
54    /// status === code
55    ExactStatus(u16),
56    /// Schema validation: status in range + JSON body matches schema
57    SchemaValidation {
58        status_min: u16,
59        status_max: u16,
60        schema: serde_json::Value,
61    },
62    /// Custom: exact status + optional header regex + optional body field type checks
63    Custom {
64        expected_status: u16,
65        expected_headers: Vec<(String, String)>,
66        expected_body_fields: Vec<(String, String)>,
67    },
68}
69
70/// Progress event for SSE streaming
71#[derive(Debug, Clone, serde::Serialize)]
72#[serde(tag = "type")]
73pub enum ConformanceProgress {
74    /// Test run started
75    #[serde(rename = "started")]
76    Started { total_checks: usize },
77    /// A single check completed
78    #[serde(rename = "check_completed")]
79    CheckCompleted {
80        name: String,
81        passed: bool,
82        checks_done: usize,
83    },
84    /// All checks finished
85    #[serde(rename = "finished")]
86    Finished,
87    /// An error occurred
88    #[serde(rename = "error")]
89    Error { message: String },
90}
91
92/// Result of executing a single conformance check
93#[derive(Debug)]
94struct CheckResult {
95    name: String,
96    passed: bool,
97    failure_detail: Option<FailureDetail>,
98}
99
100/// Native conformance executor using reqwest
101pub struct NativeConformanceExecutor {
102    config: ConformanceConfig,
103    client: Client,
104    checks: Vec<ConformanceCheck>,
105}
106
107impl NativeConformanceExecutor {
108    /// Create a new executor from a `ConformanceConfig`
109    pub fn new(config: ConformanceConfig) -> Result<Self> {
110        let mut builder = Client::builder()
111            .timeout(Duration::from_secs(30))
112            .connect_timeout(Duration::from_secs(10));
113
114        if config.skip_tls_verify {
115            builder = builder.danger_accept_invalid_certs(true);
116        }
117
118        let client = builder
119            .build()
120            .map_err(|e| BenchError::Other(format!("Failed to build HTTP client: {}", e)))?;
121
122        Ok(Self {
123            config,
124            client,
125            checks: Vec::new(),
126        })
127    }
128
129    /// Populate checks from hardcoded reference endpoints (`/conformance/*`).
130    /// Used when no `--spec` is provided.
131    #[must_use]
132    pub fn with_reference_checks(mut self) -> Self {
133        // --- Parameters ---
134        if self.config.should_include_category("Parameters") {
135            self.add_ref_get("param:path:string", "/conformance/params/hello");
136            self.add_ref_get("param:path:integer", "/conformance/params/42");
137            self.add_ref_get("param:query:string", "/conformance/params/query?name=test");
138            self.add_ref_get("param:query:integer", "/conformance/params/query?count=10");
139            self.add_ref_get("param:query:array", "/conformance/params/query?tags=a&tags=b");
140            self.checks.push(ConformanceCheck {
141                name: "param:header".to_string(),
142                method: Method::GET,
143                path: "/conformance/params/header".to_string(),
144                headers: self
145                    .merge_headers(vec![("X-Custom-Param".to_string(), "test-value".to_string())]),
146                body: None,
147                validation: CheckValidation::StatusRange {
148                    min: 200,
149                    max_exclusive: 500,
150                },
151            });
152            self.checks.push(ConformanceCheck {
153                name: "param:cookie".to_string(),
154                method: Method::GET,
155                path: "/conformance/params/cookie".to_string(),
156                headers: self
157                    .merge_headers(vec![("Cookie".to_string(), "session=abc123".to_string())]),
158                body: None,
159                validation: CheckValidation::StatusRange {
160                    min: 200,
161                    max_exclusive: 500,
162                },
163            });
164        }
165
166        // --- Request Bodies ---
167        if self.config.should_include_category("Request Bodies") {
168            self.checks.push(ConformanceCheck {
169                name: "body:json".to_string(),
170                method: Method::POST,
171                path: "/conformance/body/json".to_string(),
172                headers: self.merge_headers(vec![(
173                    "Content-Type".to_string(),
174                    "application/json".to_string(),
175                )]),
176                body: Some(CheckBody::Json(serde_json::json!({"name": "test", "value": 42}))),
177                validation: CheckValidation::StatusRange {
178                    min: 200,
179                    max_exclusive: 500,
180                },
181            });
182            self.checks.push(ConformanceCheck {
183                name: "body:form-urlencoded".to_string(),
184                method: Method::POST,
185                path: "/conformance/body/form".to_string(),
186                headers: self.custom_headers_only(),
187                body: Some(CheckBody::FormUrlencoded(vec![
188                    ("field1".to_string(), "value1".to_string()),
189                    ("field2".to_string(), "value2".to_string()),
190                ])),
191                validation: CheckValidation::StatusRange {
192                    min: 200,
193                    max_exclusive: 500,
194                },
195            });
196            self.checks.push(ConformanceCheck {
197                name: "body:multipart".to_string(),
198                method: Method::POST,
199                path: "/conformance/body/multipart".to_string(),
200                headers: self.custom_headers_only(),
201                body: Some(CheckBody::Raw {
202                    content: "test content".to_string(),
203                    content_type: "text/plain".to_string(),
204                }),
205                validation: CheckValidation::StatusRange {
206                    min: 200,
207                    max_exclusive: 500,
208                },
209            });
210        }
211
212        // --- Schema Types ---
213        if self.config.should_include_category("Schema Types") {
214            let types = [
215                ("string", r#"{"value": "hello"}"#, "schema:string"),
216                ("integer", r#"{"value": 42}"#, "schema:integer"),
217                ("number", r#"{"value": 3.14}"#, "schema:number"),
218                ("boolean", r#"{"value": true}"#, "schema:boolean"),
219                ("array", r#"{"value": [1, 2, 3]}"#, "schema:array"),
220                ("object", r#"{"value": {"nested": "data"}}"#, "schema:object"),
221            ];
222            for (type_name, body_str, check_name) in types {
223                self.checks.push(ConformanceCheck {
224                    name: check_name.to_string(),
225                    method: Method::POST,
226                    path: format!("/conformance/schema/{}", type_name),
227                    headers: self.merge_headers(vec![(
228                        "Content-Type".to_string(),
229                        "application/json".to_string(),
230                    )]),
231                    body: Some(CheckBody::Json(
232                        serde_json::from_str(body_str).expect("valid JSON"),
233                    )),
234                    validation: CheckValidation::StatusRange {
235                        min: 200,
236                        max_exclusive: 500,
237                    },
238                });
239            }
240        }
241
242        // --- Composition ---
243        if self.config.should_include_category("Composition") {
244            let compositions = [
245                ("oneOf", r#"{"type": "string", "value": "test"}"#, "composition:oneOf"),
246                ("anyOf", r#"{"value": "test"}"#, "composition:anyOf"),
247                ("allOf", r#"{"name": "test", "id": 1}"#, "composition:allOf"),
248            ];
249            for (kind, body_str, check_name) in compositions {
250                self.checks.push(ConformanceCheck {
251                    name: check_name.to_string(),
252                    method: Method::POST,
253                    path: format!("/conformance/composition/{}", kind),
254                    headers: self.merge_headers(vec![(
255                        "Content-Type".to_string(),
256                        "application/json".to_string(),
257                    )]),
258                    body: Some(CheckBody::Json(
259                        serde_json::from_str(body_str).expect("valid JSON"),
260                    )),
261                    validation: CheckValidation::StatusRange {
262                        min: 200,
263                        max_exclusive: 500,
264                    },
265                });
266            }
267        }
268
269        // --- String Formats ---
270        if self.config.should_include_category("String Formats") {
271            let formats = [
272                ("date", r#"{"value": "2024-01-15"}"#, "format:date"),
273                ("date-time", r#"{"value": "2024-01-15T10:30:00Z"}"#, "format:date-time"),
274                ("email", r#"{"value": "test@example.com"}"#, "format:email"),
275                ("uuid", r#"{"value": "550e8400-e29b-41d4-a716-446655440000"}"#, "format:uuid"),
276                ("uri", r#"{"value": "https://example.com/path"}"#, "format:uri"),
277                ("ipv4", r#"{"value": "192.168.1.1"}"#, "format:ipv4"),
278                ("ipv6", r#"{"value": "::1"}"#, "format:ipv6"),
279            ];
280            for (fmt, body_str, check_name) in formats {
281                self.checks.push(ConformanceCheck {
282                    name: check_name.to_string(),
283                    method: Method::POST,
284                    path: format!("/conformance/formats/{}", fmt),
285                    headers: self.merge_headers(vec![(
286                        "Content-Type".to_string(),
287                        "application/json".to_string(),
288                    )]),
289                    body: Some(CheckBody::Json(
290                        serde_json::from_str(body_str).expect("valid JSON"),
291                    )),
292                    validation: CheckValidation::StatusRange {
293                        min: 200,
294                        max_exclusive: 500,
295                    },
296                });
297            }
298        }
299
300        // --- Constraints ---
301        if self.config.should_include_category("Constraints") {
302            let constraints = [
303                ("required", r#"{"required_field": "present"}"#, "constraint:required"),
304                ("optional", r#"{}"#, "constraint:optional"),
305                ("minmax", r#"{"value": 50}"#, "constraint:minmax"),
306                ("pattern", r#"{"value": "ABC-123"}"#, "constraint:pattern"),
307                ("enum", r#"{"status": "active"}"#, "constraint:enum"),
308            ];
309            for (kind, body_str, check_name) in constraints {
310                self.checks.push(ConformanceCheck {
311                    name: check_name.to_string(),
312                    method: Method::POST,
313                    path: format!("/conformance/constraints/{}", kind),
314                    headers: self.merge_headers(vec![(
315                        "Content-Type".to_string(),
316                        "application/json".to_string(),
317                    )]),
318                    body: Some(CheckBody::Json(
319                        serde_json::from_str(body_str).expect("valid JSON"),
320                    )),
321                    validation: CheckValidation::StatusRange {
322                        min: 200,
323                        max_exclusive: 500,
324                    },
325                });
326            }
327        }
328
329        // --- Response Codes ---
330        if self.config.should_include_category("Response Codes") {
331            for (code_str, check_name) in [
332                ("200", "response:200"),
333                ("201", "response:201"),
334                ("204", "response:204"),
335                ("400", "response:400"),
336                ("404", "response:404"),
337            ] {
338                let code: u16 = code_str.parse().unwrap();
339                self.checks.push(ConformanceCheck {
340                    name: check_name.to_string(),
341                    method: Method::GET,
342                    path: format!("/conformance/responses/{}", code_str),
343                    headers: self.custom_headers_only(),
344                    body: None,
345                    validation: CheckValidation::ExactStatus(code),
346                });
347            }
348        }
349
350        // --- HTTP Methods ---
351        if self.config.should_include_category("HTTP Methods") {
352            self.add_ref_get("method:GET", "/conformance/methods");
353            for (method, check_name) in [
354                (Method::POST, "method:POST"),
355                (Method::PUT, "method:PUT"),
356                (Method::PATCH, "method:PATCH"),
357            ] {
358                self.checks.push(ConformanceCheck {
359                    name: check_name.to_string(),
360                    method,
361                    path: "/conformance/methods".to_string(),
362                    headers: self.merge_headers(vec![(
363                        "Content-Type".to_string(),
364                        "application/json".to_string(),
365                    )]),
366                    body: Some(CheckBody::Json(serde_json::json!({"action": "test"}))),
367                    validation: CheckValidation::StatusRange {
368                        min: 200,
369                        max_exclusive: 500,
370                    },
371                });
372            }
373            for (method, check_name) in [
374                (Method::DELETE, "method:DELETE"),
375                (Method::HEAD, "method:HEAD"),
376                (Method::OPTIONS, "method:OPTIONS"),
377            ] {
378                self.checks.push(ConformanceCheck {
379                    name: check_name.to_string(),
380                    method,
381                    path: "/conformance/methods".to_string(),
382                    headers: self.custom_headers_only(),
383                    body: None,
384                    validation: CheckValidation::StatusRange {
385                        min: 200,
386                        max_exclusive: 500,
387                    },
388                });
389            }
390        }
391
392        // --- Content Types ---
393        if self.config.should_include_category("Content Types") {
394            self.checks.push(ConformanceCheck {
395                name: "content:negotiation".to_string(),
396                method: Method::GET,
397                path: "/conformance/content-types".to_string(),
398                headers: self
399                    .merge_headers(vec![("Accept".to_string(), "application/json".to_string())]),
400                body: None,
401                validation: CheckValidation::StatusRange {
402                    min: 200,
403                    max_exclusive: 500,
404                },
405            });
406        }
407
408        // --- Security ---
409        if self.config.should_include_category("Security") {
410            // Bearer
411            self.checks.push(ConformanceCheck {
412                name: "security:bearer".to_string(),
413                method: Method::GET,
414                path: "/conformance/security/bearer".to_string(),
415                headers: self.merge_headers(vec![(
416                    "Authorization".to_string(),
417                    "Bearer test-token-123".to_string(),
418                )]),
419                body: None,
420                validation: CheckValidation::StatusRange {
421                    min: 200,
422                    max_exclusive: 500,
423                },
424            });
425
426            // API Key
427            let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
428            self.checks.push(ConformanceCheck {
429                name: "security:apikey".to_string(),
430                method: Method::GET,
431                path: "/conformance/security/apikey".to_string(),
432                headers: self.merge_headers(vec![("X-API-Key".to_string(), api_key.to_string())]),
433                body: None,
434                validation: CheckValidation::StatusRange {
435                    min: 200,
436                    max_exclusive: 500,
437                },
438            });
439
440            // Basic auth
441            let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
442            use base64::Engine;
443            let encoded = base64::engine::general_purpose::STANDARD.encode(basic_creds.as_bytes());
444            self.checks.push(ConformanceCheck {
445                name: "security:basic".to_string(),
446                method: Method::GET,
447                path: "/conformance/security/basic".to_string(),
448                headers: self.merge_headers(vec![(
449                    "Authorization".to_string(),
450                    format!("Basic {}", encoded),
451                )]),
452                body: None,
453                validation: CheckValidation::StatusRange {
454                    min: 200,
455                    max_exclusive: 500,
456                },
457            });
458        }
459
460        self
461    }
462
463    /// Populate checks from annotated spec operations (spec-driven mode)
464    #[must_use]
465    pub fn with_spec_driven_checks(mut self, operations: &[AnnotatedOperation]) -> Self {
466        // Track which features have been seen to deduplicate in default mode
467        let mut feature_seen: HashSet<&'static str> = HashSet::new();
468
469        for op in operations {
470            for feature in &op.features {
471                let category = feature.category();
472                if !self.config.should_include_category(category) {
473                    continue;
474                }
475
476                let check_name_base = feature.check_name();
477
478                if self.config.all_operations {
479                    // All-operations mode: test every operation with path-qualified names
480                    let check_name = format!("{}:{}", check_name_base, op.path);
481                    let check = self.build_spec_check(&check_name, op, feature);
482                    self.checks.push(check);
483                } else {
484                    // Default mode: one representative operation per feature
485                    if feature_seen.insert(check_name_base) {
486                        let check_name = format!("{}:{}", check_name_base, op.path);
487                        let check = self.build_spec_check(&check_name, op, feature);
488                        self.checks.push(check);
489                    }
490                }
491            }
492        }
493
494        self
495    }
496
497    /// Load custom checks from the configured YAML file
498    pub fn with_custom_checks(mut self) -> Result<Self> {
499        let path = match &self.config.custom_checks_file {
500            Some(p) => p.clone(),
501            None => return Ok(self),
502        };
503        let custom_config = CustomConformanceConfig::from_file(&path)?;
504        for check in &custom_config.custom_checks {
505            self.add_custom_check(check);
506        }
507        Ok(self)
508    }
509
510    /// Return the number of checks that will be executed
511    pub fn check_count(&self) -> usize {
512        self.checks.len()
513    }
514
515    /// Execute all checks and return a `ConformanceReport`
516    pub async fn execute(&self) -> Result<ConformanceReport> {
517        let mut results = Vec::with_capacity(self.checks.len());
518        let delay = self.config.request_delay_ms;
519
520        for (i, check) in self.checks.iter().enumerate() {
521            if delay > 0 && i > 0 {
522                tokio::time::sleep(Duration::from_millis(delay)).await;
523            }
524            let result = self.execute_check(check).await;
525            results.push(result);
526        }
527
528        Ok(Self::aggregate(results))
529    }
530
531    /// Execute all checks with progress events sent to the channel
532    pub async fn execute_with_progress(
533        &self,
534        tx: mpsc::Sender<ConformanceProgress>,
535    ) -> Result<ConformanceReport> {
536        let total = self.checks.len();
537        let delay = self.config.request_delay_ms;
538        let _ = tx
539            .send(ConformanceProgress::Started {
540                total_checks: total,
541            })
542            .await;
543
544        let mut results = Vec::with_capacity(total);
545
546        for (i, check) in self.checks.iter().enumerate() {
547            if delay > 0 && i > 0 {
548                tokio::time::sleep(Duration::from_millis(delay)).await;
549            }
550            let result = self.execute_check(check).await;
551            let passed = result.passed;
552            let name = result.name.clone();
553            results.push(result);
554
555            let _ = tx
556                .send(ConformanceProgress::CheckCompleted {
557                    name,
558                    passed,
559                    checks_done: i + 1,
560                })
561                .await;
562        }
563
564        let _ = tx.send(ConformanceProgress::Finished).await;
565        Ok(Self::aggregate(results))
566    }
567
568    /// Execute a single check
569    async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
570        let base_url = self.config.effective_base_url();
571        let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
572
573        let mut request = self.client.request(check.method.clone(), &url);
574
575        // Add headers
576        for (name, value) in &check.headers {
577            request = request.header(name.as_str(), value.as_str());
578        }
579
580        // Add body
581        match &check.body {
582            Some(CheckBody::Json(value)) => {
583                request = request.json(value);
584            }
585            Some(CheckBody::FormUrlencoded(fields)) => {
586                request = request.form(fields);
587            }
588            Some(CheckBody::Raw {
589                content,
590                content_type,
591            }) => {
592                // For multipart, use the multipart API
593                if content_type == "text/plain" && check.path.contains("multipart") {
594                    let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
595                        .file_name("test.txt")
596                        .mime_str(content_type)
597                        .unwrap_or_else(|_| {
598                            reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
599                        });
600                    let form = reqwest::multipart::Form::new().part("field", part);
601                    request = request.multipart(form);
602                } else {
603                    request =
604                        request.header("Content-Type", content_type.as_str()).body(content.clone());
605                }
606            }
607            None => {}
608        }
609
610        let response = match request.send().await {
611            Ok(resp) => resp,
612            Err(e) => {
613                return CheckResult {
614                    name: check.name.clone(),
615                    passed: false,
616                    failure_detail: Some(FailureDetail {
617                        check: check.name.clone(),
618                        request: FailureRequest {
619                            method: check.method.to_string(),
620                            url: url.clone(),
621                            headers: HashMap::new(),
622                            body: String::new(),
623                        },
624                        response: FailureResponse {
625                            status: 0,
626                            headers: HashMap::new(),
627                            body: format!("Request failed: {}", e),
628                        },
629                        expected: format!("{:?}", check.validation),
630                    }),
631                };
632            }
633        };
634
635        let status = response.status().as_u16();
636        let resp_headers: HashMap<String, String> = response
637            .headers()
638            .iter()
639            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
640            .collect();
641        let resp_body = response.text().await.unwrap_or_default();
642
643        let passed = self.validate_response(&check.validation, status, &resp_headers, &resp_body);
644
645        let failure_detail = if !passed {
646            Some(FailureDetail {
647                check: check.name.clone(),
648                request: FailureRequest {
649                    method: check.method.to_string(),
650                    url,
651                    headers: check.headers.iter().cloned().collect(),
652                    body: match &check.body {
653                        Some(CheckBody::Json(v)) => v.to_string(),
654                        Some(CheckBody::FormUrlencoded(f)) => f
655                            .iter()
656                            .map(|(k, v)| format!("{}={}", k, v))
657                            .collect::<Vec<_>>()
658                            .join("&"),
659                        Some(CheckBody::Raw { content, .. }) => content.clone(),
660                        None => String::new(),
661                    },
662                },
663                response: FailureResponse {
664                    status,
665                    headers: resp_headers,
666                    body: if resp_body.len() > 500 {
667                        format!("{}...", &resp_body[..500])
668                    } else {
669                        resp_body
670                    },
671                },
672                expected: Self::describe_validation(&check.validation),
673            })
674        } else {
675            None
676        };
677
678        CheckResult {
679            name: check.name.clone(),
680            passed,
681            failure_detail,
682        }
683    }
684
685    /// Validate a response against the check's validation rules
686    fn validate_response(
687        &self,
688        validation: &CheckValidation,
689        status: u16,
690        headers: &HashMap<String, String>,
691        body: &str,
692    ) -> bool {
693        match validation {
694            CheckValidation::StatusRange { min, max_exclusive } => {
695                status >= *min && status < *max_exclusive
696            }
697            CheckValidation::ExactStatus(expected) => status == *expected,
698            CheckValidation::SchemaValidation {
699                status_min,
700                status_max,
701                schema,
702            } => {
703                if status < *status_min || status >= *status_max {
704                    return false;
705                }
706                // Parse body as JSON and validate against schema
707                let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
708                    return false;
709                };
710                jsonschema::is_valid(schema, &body_value)
711            }
712            CheckValidation::Custom {
713                expected_status,
714                expected_headers,
715                expected_body_fields,
716            } => {
717                if status != *expected_status {
718                    return false;
719                }
720                // Check headers with regex
721                for (header_name, pattern) in expected_headers {
722                    let header_val = headers
723                        .get(header_name)
724                        .or_else(|| headers.get(&header_name.to_lowercase()))
725                        .map(|s| s.as_str())
726                        .unwrap_or("");
727                    if let Ok(re) = regex::Regex::new(pattern) {
728                        if !re.is_match(header_val) {
729                            return false;
730                        }
731                    }
732                }
733                // Check body field types
734                if !expected_body_fields.is_empty() {
735                    let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
736                        return false;
737                    };
738                    for (field_name, field_type) in expected_body_fields {
739                        let field = &body_value[field_name];
740                        let ok = match field_type.as_str() {
741                            "string" => field.is_string(),
742                            "integer" => field.is_i64() || field.is_u64(),
743                            "number" => field.is_number(),
744                            "boolean" => field.is_boolean(),
745                            "array" => field.is_array(),
746                            "object" => field.is_object(),
747                            _ => !field.is_null(),
748                        };
749                        if !ok {
750                            return false;
751                        }
752                    }
753                }
754                true
755            }
756        }
757    }
758
759    /// Human-readable validation description for failure reports
760    fn describe_validation(validation: &CheckValidation) -> String {
761        match validation {
762            CheckValidation::StatusRange { min, max_exclusive } => {
763                format!("status >= {} && status < {}", min, max_exclusive)
764            }
765            CheckValidation::ExactStatus(code) => format!("status === {}", code),
766            CheckValidation::SchemaValidation {
767                status_min,
768                status_max,
769                ..
770            } => {
771                format!("status >= {} && status < {} + schema validation", status_min, status_max)
772            }
773            CheckValidation::Custom {
774                expected_status, ..
775            } => {
776                format!("status === {}", expected_status)
777            }
778        }
779    }
780
781    /// Aggregate check results into a `ConformanceReport`
782    fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
783        let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
784        let mut failure_details = Vec::new();
785
786        for result in results {
787            let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
788            if result.passed {
789                entry.0 += 1;
790            } else {
791                entry.1 += 1;
792            }
793            if let Some(detail) = result.failure_detail {
794                failure_details.push(detail);
795            }
796        }
797
798        ConformanceReport::from_results(check_results, failure_details)
799    }
800
801    // --- Helper methods ---
802
803    /// Build a spec-driven check from an annotated operation and feature
804    fn build_spec_check(
805        &self,
806        check_name: &str,
807        op: &AnnotatedOperation,
808        feature: &ConformanceFeature,
809    ) -> ConformanceCheck {
810        // Build URL path with parameters substituted
811        let mut url_path = op.path.clone();
812        for (name, value) in &op.path_params {
813            url_path = url_path.replace(&format!("{{{}}}", name), value);
814        }
815        // Append query params
816        if !op.query_params.is_empty() {
817            let qs: Vec<String> =
818                op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
819            url_path = format!("{}?{}", url_path, qs.join("&"));
820        }
821
822        // Build effective headers
823        let mut effective_headers = self.effective_headers(&op.header_params);
824
825        // For non-default response codes, add mock server header
826        if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
827            let code = match feature {
828                ConformanceFeature::Response400 => "400",
829                ConformanceFeature::Response404 => "404",
830                _ => unreachable!(),
831            };
832            effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
833        }
834
835        // Inject auth headers for security checks or secured endpoints
836        let needs_auth = matches!(
837            feature,
838            ConformanceFeature::SecurityBearer
839                | ConformanceFeature::SecurityBasic
840                | ConformanceFeature::SecurityApiKey
841        ) || !op.security_schemes.is_empty();
842
843        if needs_auth {
844            self.inject_security_headers(&op.security_schemes, &mut effective_headers);
845        }
846
847        // Determine method
848        let method = match op.method.as_str() {
849            "GET" => Method::GET,
850            "POST" => Method::POST,
851            "PUT" => Method::PUT,
852            "PATCH" => Method::PATCH,
853            "DELETE" => Method::DELETE,
854            "HEAD" => Method::HEAD,
855            "OPTIONS" => Method::OPTIONS,
856            _ => Method::GET,
857        };
858
859        // Determine body
860        let body = match method {
861            Method::POST | Method::PUT | Method::PATCH => {
862                if let Some(sample) = &op.sample_body {
863                    // Add Content-Type if not present
864                    let content_type =
865                        op.request_body_content_type.as_deref().unwrap_or("application/json");
866                    if !effective_headers
867                        .iter()
868                        .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
869                    {
870                        effective_headers
871                            .push(("Content-Type".to_string(), content_type.to_string()));
872                    }
873                    match content_type {
874                        "application/x-www-form-urlencoded" => {
875                            // Parse as form fields
876                            let fields: Vec<(String, String)> = serde_json::from_str::<
877                                serde_json::Value,
878                            >(
879                                sample
880                            )
881                            .ok()
882                            .and_then(|v| {
883                                v.as_object().map(|obj| {
884                                    obj.iter()
885                                        .map(|(k, v)| {
886                                            (k.clone(), v.as_str().unwrap_or("").to_string())
887                                        })
888                                        .collect()
889                                })
890                            })
891                            .unwrap_or_default();
892                            Some(CheckBody::FormUrlencoded(fields))
893                        }
894                        _ => {
895                            // Try JSON, fall back to raw
896                            match serde_json::from_str::<serde_json::Value>(sample) {
897                                Ok(v) => Some(CheckBody::Json(v)),
898                                Err(_) => Some(CheckBody::Raw {
899                                    content: sample.clone(),
900                                    content_type: content_type.to_string(),
901                                }),
902                            }
903                        }
904                    }
905                } else {
906                    None
907                }
908            }
909            _ => None,
910        };
911
912        // Determine validation
913        let validation = self.determine_validation(feature, op);
914
915        ConformanceCheck {
916            name: check_name.to_string(),
917            method,
918            path: url_path,
919            headers: effective_headers,
920            body,
921            validation,
922        }
923    }
924
925    /// Determine validation strategy based on the conformance feature
926    fn determine_validation(
927        &self,
928        feature: &ConformanceFeature,
929        op: &AnnotatedOperation,
930    ) -> CheckValidation {
931        match feature {
932            ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
933            ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
934            ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
935            ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
936            ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
937            ConformanceFeature::SecurityBearer
938            | ConformanceFeature::SecurityBasic
939            | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
940                min: 200,
941                max_exclusive: 400,
942            },
943            ConformanceFeature::ResponseValidation => {
944                if let Some(schema) = &op.response_schema {
945                    // Convert openapiv3 Schema to JSON Schema value for jsonschema crate
946                    let schema_json = openapi_schema_to_json_schema(schema);
947                    CheckValidation::SchemaValidation {
948                        status_min: 200,
949                        status_max: 500,
950                        schema: schema_json,
951                    }
952                } else {
953                    CheckValidation::StatusRange {
954                        min: 200,
955                        max_exclusive: 500,
956                    }
957                }
958            }
959            _ => CheckValidation::StatusRange {
960                min: 200,
961                max_exclusive: 500,
962            },
963        }
964    }
965
966    /// Add a simple GET reference check with default status range validation
967    fn add_ref_get(&mut self, name: &str, path: &str) {
968        self.checks.push(ConformanceCheck {
969            name: name.to_string(),
970            method: Method::GET,
971            path: path.to_string(),
972            headers: self.custom_headers_only(),
973            body: None,
974            validation: CheckValidation::StatusRange {
975                min: 200,
976                max_exclusive: 500,
977            },
978        });
979    }
980
981    /// Merge spec-derived headers with custom headers (custom overrides spec)
982    fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
983        let mut headers = Vec::new();
984        for (k, v) in spec_headers {
985            // Skip if custom headers override this one
986            if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
987                continue;
988            }
989            headers.push((k.clone(), v.clone()));
990        }
991        // Append custom headers
992        headers.extend(self.config.custom_headers.clone());
993        headers
994    }
995
996    /// Merge provided headers with custom headers
997    fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
998        for (k, v) in &self.config.custom_headers {
999            if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1000                headers.push((k.clone(), v.clone()));
1001            }
1002        }
1003        headers
1004    }
1005
1006    /// Return only custom headers (for checks that don't have spec-derived headers)
1007    fn custom_headers_only(&self) -> Vec<(String, String)> {
1008        self.config.custom_headers.clone()
1009    }
1010
1011    /// Inject security headers based on resolved security schemes
1012    fn inject_security_headers(
1013        &self,
1014        schemes: &[SecuritySchemeInfo],
1015        headers: &mut Vec<(String, String)>,
1016    ) {
1017        let mut to_add: Vec<(String, String)> = Vec::new();
1018
1019        for scheme in schemes {
1020            match scheme {
1021                SecuritySchemeInfo::Bearer => {
1022                    if !Self::header_present("Authorization", headers, &self.config.custom_headers)
1023                    {
1024                        to_add.push((
1025                            "Authorization".to_string(),
1026                            "Bearer mockforge-conformance-test-token".to_string(),
1027                        ));
1028                    }
1029                }
1030                SecuritySchemeInfo::Basic => {
1031                    if !Self::header_present("Authorization", headers, &self.config.custom_headers)
1032                    {
1033                        let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1034                        use base64::Engine;
1035                        let encoded =
1036                            base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1037                        to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1038                    }
1039                }
1040                SecuritySchemeInfo::ApiKey { location, name } => match location {
1041                    ApiKeyLocation::Header => {
1042                        if !Self::header_present(name, headers, &self.config.custom_headers) {
1043                            let key = self
1044                                .config
1045                                .api_key
1046                                .as_deref()
1047                                .unwrap_or("mockforge-conformance-test-key");
1048                            to_add.push((name.clone(), key.to_string()));
1049                        }
1050                    }
1051                    ApiKeyLocation::Cookie => {
1052                        if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1053                            to_add.push((
1054                                "Cookie".to_string(),
1055                                format!("{}=mockforge-conformance-test-session", name),
1056                            ));
1057                        }
1058                    }
1059                    ApiKeyLocation::Query => {
1060                        // Handled in URL, not headers
1061                    }
1062                },
1063            }
1064        }
1065
1066        headers.extend(to_add);
1067    }
1068
1069    /// Check if a header name is present in either the existing headers or custom headers
1070    fn header_present(
1071        name: &str,
1072        headers: &[(String, String)],
1073        custom_headers: &[(String, String)],
1074    ) -> bool {
1075        headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1076            || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1077    }
1078
1079    /// Add a custom check from YAML config
1080    fn add_custom_check(&mut self, check: &CustomCheck) {
1081        let method = match check.method.to_uppercase().as_str() {
1082            "GET" => Method::GET,
1083            "POST" => Method::POST,
1084            "PUT" => Method::PUT,
1085            "PATCH" => Method::PATCH,
1086            "DELETE" => Method::DELETE,
1087            "HEAD" => Method::HEAD,
1088            "OPTIONS" => Method::OPTIONS,
1089            _ => Method::GET,
1090        };
1091
1092        // Build headers
1093        let mut headers: Vec<(String, String)> =
1094            check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1095        // Add global custom headers (check-specific take priority)
1096        for (k, v) in &self.config.custom_headers {
1097            if !check.headers.contains_key(k) {
1098                headers.push((k.clone(), v.clone()));
1099            }
1100        }
1101        // Add Content-Type for JSON body if not present
1102        if check.body.is_some()
1103            && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1104        {
1105            headers.push(("Content-Type".to_string(), "application/json".to_string()));
1106        }
1107
1108        // Body
1109        let body = check
1110            .body
1111            .as_ref()
1112            .and_then(|b| serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json));
1113
1114        // Build expected headers for validation
1115        let expected_headers: Vec<(String, String)> =
1116            check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1117
1118        // Build expected body fields
1119        let expected_body_fields: Vec<(String, String)> = check
1120            .expected_body_fields
1121            .iter()
1122            .map(|f| (f.name.clone(), f.field_type.clone()))
1123            .collect();
1124
1125        // Primary status check
1126        self.checks.push(ConformanceCheck {
1127            name: check.name.clone(),
1128            method,
1129            path: check.path.clone(),
1130            headers,
1131            body,
1132            validation: CheckValidation::Custom {
1133                expected_status: check.expected_status,
1134                expected_headers,
1135                expected_body_fields,
1136            },
1137        });
1138    }
1139}
1140
1141/// Convert an `openapiv3::Schema` to a JSON Schema `serde_json::Value`
1142/// suitable for use with the `jsonschema` crate.
1143fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1144    use openapiv3::{SchemaKind, Type};
1145
1146    match &schema.schema_kind {
1147        SchemaKind::Type(Type::Object(obj)) => {
1148            let mut props = serde_json::Map::new();
1149            for (name, prop_ref) in &obj.properties {
1150                if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1151                    props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1152                }
1153            }
1154            let mut schema_obj = serde_json::json!({
1155                "type": "object",
1156                "properties": props,
1157            });
1158            if !obj.required.is_empty() {
1159                schema_obj["required"] = serde_json::Value::Array(
1160                    obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1161                );
1162            }
1163            schema_obj
1164        }
1165        SchemaKind::Type(Type::Array(arr)) => {
1166            let mut schema_obj = serde_json::json!({"type": "array"});
1167            if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1168                schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1169            }
1170            schema_obj
1171        }
1172        SchemaKind::Type(Type::String(s)) => {
1173            let mut obj = serde_json::json!({"type": "string"});
1174            if let Some(min) = s.min_length {
1175                obj["minLength"] = serde_json::json!(min);
1176            }
1177            if let Some(max) = s.max_length {
1178                obj["maxLength"] = serde_json::json!(max);
1179            }
1180            if let Some(pattern) = &s.pattern {
1181                obj["pattern"] = serde_json::json!(pattern);
1182            }
1183            if !s.enumeration.is_empty() {
1184                obj["enum"] = serde_json::Value::Array(
1185                    s.enumeration
1186                        .iter()
1187                        .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1188                        .collect(),
1189                );
1190            }
1191            obj
1192        }
1193        SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1194        SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1195        SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1196        _ => serde_json::json!({}),
1197    }
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202    use super::*;
1203
1204    #[test]
1205    fn test_reference_check_count() {
1206        let config = ConformanceConfig {
1207            target_url: "http://localhost:3000".to_string(),
1208            ..Default::default()
1209        };
1210        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1211        // 7 params + 3 bodies + 6 schema + 3 composition + 7 formats + 5 constraints
1212        // + 5 response codes + 7 methods + 1 content + 3 security = 47
1213        assert_eq!(executor.check_count(), 47);
1214    }
1215
1216    #[test]
1217    fn test_reference_checks_with_category_filter() {
1218        let config = ConformanceConfig {
1219            target_url: "http://localhost:3000".to_string(),
1220            categories: Some(vec!["Parameters".to_string()]),
1221            ..Default::default()
1222        };
1223        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1224        assert_eq!(executor.check_count(), 7);
1225    }
1226
1227    #[test]
1228    fn test_validate_status_range() {
1229        let config = ConformanceConfig {
1230            target_url: "http://localhost:3000".to_string(),
1231            ..Default::default()
1232        };
1233        let executor = NativeConformanceExecutor::new(config).unwrap();
1234        let headers = HashMap::new();
1235
1236        assert!(executor.validate_response(
1237            &CheckValidation::StatusRange {
1238                min: 200,
1239                max_exclusive: 500,
1240            },
1241            200,
1242            &headers,
1243            "",
1244        ));
1245        assert!(executor.validate_response(
1246            &CheckValidation::StatusRange {
1247                min: 200,
1248                max_exclusive: 500,
1249            },
1250            404,
1251            &headers,
1252            "",
1253        ));
1254        assert!(!executor.validate_response(
1255            &CheckValidation::StatusRange {
1256                min: 200,
1257                max_exclusive: 500,
1258            },
1259            500,
1260            &headers,
1261            "",
1262        ));
1263    }
1264
1265    #[test]
1266    fn test_validate_exact_status() {
1267        let config = ConformanceConfig {
1268            target_url: "http://localhost:3000".to_string(),
1269            ..Default::default()
1270        };
1271        let executor = NativeConformanceExecutor::new(config).unwrap();
1272        let headers = HashMap::new();
1273
1274        assert!(executor.validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "",));
1275        assert!(!executor.validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "",));
1276    }
1277
1278    #[test]
1279    fn test_validate_schema() {
1280        let config = ConformanceConfig {
1281            target_url: "http://localhost:3000".to_string(),
1282            ..Default::default()
1283        };
1284        let executor = NativeConformanceExecutor::new(config).unwrap();
1285        let headers = HashMap::new();
1286
1287        let schema = serde_json::json!({
1288            "type": "object",
1289            "properties": {
1290                "name": {"type": "string"},
1291                "age": {"type": "integer"}
1292            },
1293            "required": ["name"]
1294        });
1295
1296        assert!(executor.validate_response(
1297            &CheckValidation::SchemaValidation {
1298                status_min: 200,
1299                status_max: 300,
1300                schema: schema.clone(),
1301            },
1302            200,
1303            &headers,
1304            r#"{"name": "test", "age": 25}"#,
1305        ));
1306
1307        // Missing required field
1308        assert!(!executor.validate_response(
1309            &CheckValidation::SchemaValidation {
1310                status_min: 200,
1311                status_max: 300,
1312                schema: schema.clone(),
1313            },
1314            200,
1315            &headers,
1316            r#"{"age": 25}"#,
1317        ));
1318    }
1319
1320    #[test]
1321    fn test_validate_custom() {
1322        let config = ConformanceConfig {
1323            target_url: "http://localhost:3000".to_string(),
1324            ..Default::default()
1325        };
1326        let executor = NativeConformanceExecutor::new(config).unwrap();
1327        let mut headers = HashMap::new();
1328        headers.insert("content-type".to_string(), "application/json".to_string());
1329
1330        assert!(executor.validate_response(
1331            &CheckValidation::Custom {
1332                expected_status: 200,
1333                expected_headers: vec![(
1334                    "content-type".to_string(),
1335                    "application/json".to_string(),
1336                )],
1337                expected_body_fields: vec![("name".to_string(), "string".to_string())],
1338            },
1339            200,
1340            &headers,
1341            r#"{"name": "test"}"#,
1342        ));
1343
1344        // Wrong status
1345        assert!(!executor.validate_response(
1346            &CheckValidation::Custom {
1347                expected_status: 200,
1348                expected_headers: vec![],
1349                expected_body_fields: vec![],
1350            },
1351            404,
1352            &headers,
1353            "",
1354        ));
1355    }
1356
1357    #[test]
1358    fn test_aggregate_results() {
1359        let results = vec![
1360            CheckResult {
1361                name: "check1".to_string(),
1362                passed: true,
1363                failure_detail: None,
1364            },
1365            CheckResult {
1366                name: "check2".to_string(),
1367                passed: false,
1368                failure_detail: Some(FailureDetail {
1369                    check: "check2".to_string(),
1370                    request: FailureRequest {
1371                        method: "GET".to_string(),
1372                        url: "http://example.com".to_string(),
1373                        headers: HashMap::new(),
1374                        body: String::new(),
1375                    },
1376                    response: FailureResponse {
1377                        status: 500,
1378                        headers: HashMap::new(),
1379                        body: "error".to_string(),
1380                    },
1381                    expected: "status >= 200 && status < 500".to_string(),
1382                }),
1383            },
1384        ];
1385
1386        let report = NativeConformanceExecutor::aggregate(results);
1387        let raw = report.raw_check_results();
1388        assert_eq!(raw.get("check1"), Some(&(1, 0)));
1389        assert_eq!(raw.get("check2"), Some(&(0, 1)));
1390    }
1391
1392    #[test]
1393    fn test_custom_check_building() {
1394        let config = ConformanceConfig {
1395            target_url: "http://localhost:3000".to_string(),
1396            ..Default::default()
1397        };
1398        let mut executor = NativeConformanceExecutor::new(config).unwrap();
1399
1400        let custom = CustomCheck {
1401            name: "custom:test-get".to_string(),
1402            path: "/api/test".to_string(),
1403            method: "GET".to_string(),
1404            expected_status: 200,
1405            body: None,
1406            expected_headers: std::collections::HashMap::new(),
1407            expected_body_fields: vec![],
1408            headers: std::collections::HashMap::new(),
1409        };
1410
1411        executor.add_custom_check(&custom);
1412        assert_eq!(executor.check_count(), 1);
1413        assert_eq!(executor.checks[0].name, "custom:test-get");
1414    }
1415
1416    #[test]
1417    fn test_openapi_schema_to_json_schema_object() {
1418        use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
1419
1420        let schema = Schema {
1421            schema_data: SchemaData::default(),
1422            schema_kind: SchemaKind::Type(Type::Object(ObjectType {
1423                required: vec!["name".to_string()],
1424                ..Default::default()
1425            })),
1426        };
1427
1428        let json = openapi_schema_to_json_schema(&schema);
1429        assert_eq!(json["type"], "object");
1430        assert_eq!(json["required"][0], "name");
1431    }
1432}