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    /// If the user provides a Cookie header via --conformance-header, skip automatic
1013    /// Authorization headers (Bearer/Basic) since the user manages their own auth.
1014    fn inject_security_headers(
1015        &self,
1016        schemes: &[SecuritySchemeInfo],
1017        headers: &mut Vec<(String, String)>,
1018    ) {
1019        // If user provides Cookie header, they're using session-based auth — skip auto auth
1020        let has_cookie_auth =
1021            self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1022        let mut to_add: Vec<(String, String)> = Vec::new();
1023
1024        for scheme in schemes {
1025            match scheme {
1026                SecuritySchemeInfo::Bearer => {
1027                    if !has_cookie_auth
1028                        && !Self::header_present(
1029                            "Authorization",
1030                            headers,
1031                            &self.config.custom_headers,
1032                        )
1033                    {
1034                        to_add.push((
1035                            "Authorization".to_string(),
1036                            "Bearer mockforge-conformance-test-token".to_string(),
1037                        ));
1038                    }
1039                }
1040                SecuritySchemeInfo::Basic => {
1041                    if !has_cookie_auth
1042                        && !Self::header_present(
1043                            "Authorization",
1044                            headers,
1045                            &self.config.custom_headers,
1046                        )
1047                    {
1048                        let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1049                        use base64::Engine;
1050                        let encoded =
1051                            base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1052                        to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1053                    }
1054                }
1055                SecuritySchemeInfo::ApiKey { location, name } => match location {
1056                    ApiKeyLocation::Header => {
1057                        if !Self::header_present(name, headers, &self.config.custom_headers) {
1058                            let key = self
1059                                .config
1060                                .api_key
1061                                .as_deref()
1062                                .unwrap_or("mockforge-conformance-test-key");
1063                            to_add.push((name.clone(), key.to_string()));
1064                        }
1065                    }
1066                    ApiKeyLocation::Cookie => {
1067                        if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1068                            to_add.push((
1069                                "Cookie".to_string(),
1070                                format!("{}=mockforge-conformance-test-session", name),
1071                            ));
1072                        }
1073                    }
1074                    ApiKeyLocation::Query => {
1075                        // Handled in URL, not headers
1076                    }
1077                },
1078            }
1079        }
1080
1081        headers.extend(to_add);
1082    }
1083
1084    /// Check if a header name is present in either the existing headers or custom headers
1085    fn header_present(
1086        name: &str,
1087        headers: &[(String, String)],
1088        custom_headers: &[(String, String)],
1089    ) -> bool {
1090        headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1091            || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1092    }
1093
1094    /// Add a custom check from YAML config
1095    fn add_custom_check(&mut self, check: &CustomCheck) {
1096        let method = match check.method.to_uppercase().as_str() {
1097            "GET" => Method::GET,
1098            "POST" => Method::POST,
1099            "PUT" => Method::PUT,
1100            "PATCH" => Method::PATCH,
1101            "DELETE" => Method::DELETE,
1102            "HEAD" => Method::HEAD,
1103            "OPTIONS" => Method::OPTIONS,
1104            _ => Method::GET,
1105        };
1106
1107        // Build headers
1108        let mut headers: Vec<(String, String)> =
1109            check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1110        // Add global custom headers (check-specific take priority)
1111        for (k, v) in &self.config.custom_headers {
1112            if !check.headers.contains_key(k) {
1113                headers.push((k.clone(), v.clone()));
1114            }
1115        }
1116        // Add Content-Type for JSON body if not present
1117        if check.body.is_some()
1118            && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1119        {
1120            headers.push(("Content-Type".to_string(), "application/json".to_string()));
1121        }
1122
1123        // Body
1124        let body = check
1125            .body
1126            .as_ref()
1127            .and_then(|b| serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json));
1128
1129        // Build expected headers for validation
1130        let expected_headers: Vec<(String, String)> =
1131            check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1132
1133        // Build expected body fields
1134        let expected_body_fields: Vec<(String, String)> = check
1135            .expected_body_fields
1136            .iter()
1137            .map(|f| (f.name.clone(), f.field_type.clone()))
1138            .collect();
1139
1140        // Primary status check
1141        self.checks.push(ConformanceCheck {
1142            name: check.name.clone(),
1143            method,
1144            path: check.path.clone(),
1145            headers,
1146            body,
1147            validation: CheckValidation::Custom {
1148                expected_status: check.expected_status,
1149                expected_headers,
1150                expected_body_fields,
1151            },
1152        });
1153    }
1154}
1155
1156/// Convert an `openapiv3::Schema` to a JSON Schema `serde_json::Value`
1157/// suitable for use with the `jsonschema` crate.
1158fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1159    use openapiv3::{SchemaKind, Type};
1160
1161    match &schema.schema_kind {
1162        SchemaKind::Type(Type::Object(obj)) => {
1163            let mut props = serde_json::Map::new();
1164            for (name, prop_ref) in &obj.properties {
1165                if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1166                    props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1167                }
1168            }
1169            let mut schema_obj = serde_json::json!({
1170                "type": "object",
1171                "properties": props,
1172            });
1173            if !obj.required.is_empty() {
1174                schema_obj["required"] = serde_json::Value::Array(
1175                    obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1176                );
1177            }
1178            schema_obj
1179        }
1180        SchemaKind::Type(Type::Array(arr)) => {
1181            let mut schema_obj = serde_json::json!({"type": "array"});
1182            if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1183                schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1184            }
1185            schema_obj
1186        }
1187        SchemaKind::Type(Type::String(s)) => {
1188            let mut obj = serde_json::json!({"type": "string"});
1189            if let Some(min) = s.min_length {
1190                obj["minLength"] = serde_json::json!(min);
1191            }
1192            if let Some(max) = s.max_length {
1193                obj["maxLength"] = serde_json::json!(max);
1194            }
1195            if let Some(pattern) = &s.pattern {
1196                obj["pattern"] = serde_json::json!(pattern);
1197            }
1198            if !s.enumeration.is_empty() {
1199                obj["enum"] = serde_json::Value::Array(
1200                    s.enumeration
1201                        .iter()
1202                        .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1203                        .collect(),
1204                );
1205            }
1206            obj
1207        }
1208        SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1209        SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1210        SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1211        _ => serde_json::json!({}),
1212    }
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217    use super::*;
1218
1219    #[test]
1220    fn test_reference_check_count() {
1221        let config = ConformanceConfig {
1222            target_url: "http://localhost:3000".to_string(),
1223            ..Default::default()
1224        };
1225        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1226        // 7 params + 3 bodies + 6 schema + 3 composition + 7 formats + 5 constraints
1227        // + 5 response codes + 7 methods + 1 content + 3 security = 47
1228        assert_eq!(executor.check_count(), 47);
1229    }
1230
1231    #[test]
1232    fn test_reference_checks_with_category_filter() {
1233        let config = ConformanceConfig {
1234            target_url: "http://localhost:3000".to_string(),
1235            categories: Some(vec!["Parameters".to_string()]),
1236            ..Default::default()
1237        };
1238        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1239        assert_eq!(executor.check_count(), 7);
1240    }
1241
1242    #[test]
1243    fn test_validate_status_range() {
1244        let config = ConformanceConfig {
1245            target_url: "http://localhost:3000".to_string(),
1246            ..Default::default()
1247        };
1248        let executor = NativeConformanceExecutor::new(config).unwrap();
1249        let headers = HashMap::new();
1250
1251        assert!(executor.validate_response(
1252            &CheckValidation::StatusRange {
1253                min: 200,
1254                max_exclusive: 500,
1255            },
1256            200,
1257            &headers,
1258            "",
1259        ));
1260        assert!(executor.validate_response(
1261            &CheckValidation::StatusRange {
1262                min: 200,
1263                max_exclusive: 500,
1264            },
1265            404,
1266            &headers,
1267            "",
1268        ));
1269        assert!(!executor.validate_response(
1270            &CheckValidation::StatusRange {
1271                min: 200,
1272                max_exclusive: 500,
1273            },
1274            500,
1275            &headers,
1276            "",
1277        ));
1278    }
1279
1280    #[test]
1281    fn test_validate_exact_status() {
1282        let config = ConformanceConfig {
1283            target_url: "http://localhost:3000".to_string(),
1284            ..Default::default()
1285        };
1286        let executor = NativeConformanceExecutor::new(config).unwrap();
1287        let headers = HashMap::new();
1288
1289        assert!(executor.validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "",));
1290        assert!(!executor.validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "",));
1291    }
1292
1293    #[test]
1294    fn test_validate_schema() {
1295        let config = ConformanceConfig {
1296            target_url: "http://localhost:3000".to_string(),
1297            ..Default::default()
1298        };
1299        let executor = NativeConformanceExecutor::new(config).unwrap();
1300        let headers = HashMap::new();
1301
1302        let schema = serde_json::json!({
1303            "type": "object",
1304            "properties": {
1305                "name": {"type": "string"},
1306                "age": {"type": "integer"}
1307            },
1308            "required": ["name"]
1309        });
1310
1311        assert!(executor.validate_response(
1312            &CheckValidation::SchemaValidation {
1313                status_min: 200,
1314                status_max: 300,
1315                schema: schema.clone(),
1316            },
1317            200,
1318            &headers,
1319            r#"{"name": "test", "age": 25}"#,
1320        ));
1321
1322        // Missing required field
1323        assert!(!executor.validate_response(
1324            &CheckValidation::SchemaValidation {
1325                status_min: 200,
1326                status_max: 300,
1327                schema: schema.clone(),
1328            },
1329            200,
1330            &headers,
1331            r#"{"age": 25}"#,
1332        ));
1333    }
1334
1335    #[test]
1336    fn test_validate_custom() {
1337        let config = ConformanceConfig {
1338            target_url: "http://localhost:3000".to_string(),
1339            ..Default::default()
1340        };
1341        let executor = NativeConformanceExecutor::new(config).unwrap();
1342        let mut headers = HashMap::new();
1343        headers.insert("content-type".to_string(), "application/json".to_string());
1344
1345        assert!(executor.validate_response(
1346            &CheckValidation::Custom {
1347                expected_status: 200,
1348                expected_headers: vec![(
1349                    "content-type".to_string(),
1350                    "application/json".to_string(),
1351                )],
1352                expected_body_fields: vec![("name".to_string(), "string".to_string())],
1353            },
1354            200,
1355            &headers,
1356            r#"{"name": "test"}"#,
1357        ));
1358
1359        // Wrong status
1360        assert!(!executor.validate_response(
1361            &CheckValidation::Custom {
1362                expected_status: 200,
1363                expected_headers: vec![],
1364                expected_body_fields: vec![],
1365            },
1366            404,
1367            &headers,
1368            "",
1369        ));
1370    }
1371
1372    #[test]
1373    fn test_aggregate_results() {
1374        let results = vec![
1375            CheckResult {
1376                name: "check1".to_string(),
1377                passed: true,
1378                failure_detail: None,
1379            },
1380            CheckResult {
1381                name: "check2".to_string(),
1382                passed: false,
1383                failure_detail: Some(FailureDetail {
1384                    check: "check2".to_string(),
1385                    request: FailureRequest {
1386                        method: "GET".to_string(),
1387                        url: "http://example.com".to_string(),
1388                        headers: HashMap::new(),
1389                        body: String::new(),
1390                    },
1391                    response: FailureResponse {
1392                        status: 500,
1393                        headers: HashMap::new(),
1394                        body: "error".to_string(),
1395                    },
1396                    expected: "status >= 200 && status < 500".to_string(),
1397                }),
1398            },
1399        ];
1400
1401        let report = NativeConformanceExecutor::aggregate(results);
1402        let raw = report.raw_check_results();
1403        assert_eq!(raw.get("check1"), Some(&(1, 0)));
1404        assert_eq!(raw.get("check2"), Some(&(0, 1)));
1405    }
1406
1407    #[test]
1408    fn test_custom_check_building() {
1409        let config = ConformanceConfig {
1410            target_url: "http://localhost:3000".to_string(),
1411            ..Default::default()
1412        };
1413        let mut executor = NativeConformanceExecutor::new(config).unwrap();
1414
1415        let custom = CustomCheck {
1416            name: "custom:test-get".to_string(),
1417            path: "/api/test".to_string(),
1418            method: "GET".to_string(),
1419            expected_status: 200,
1420            body: None,
1421            expected_headers: std::collections::HashMap::new(),
1422            expected_body_fields: vec![],
1423            headers: std::collections::HashMap::new(),
1424        };
1425
1426        executor.add_custom_check(&custom);
1427        assert_eq!(executor.check_count(), 1);
1428        assert_eq!(executor.checks[0].name, "custom:test-get");
1429    }
1430
1431    #[test]
1432    fn test_openapi_schema_to_json_schema_object() {
1433        use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
1434
1435        let schema = Schema {
1436            schema_data: SchemaData::default(),
1437            schema_kind: SchemaKind::Type(Type::Object(ObjectType {
1438                required: vec!["name".to_string()],
1439                ..Default::default()
1440            })),
1441        };
1442
1443        let json = openapi_schema_to_json_schema(&schema);
1444        assert_eq!(json["type"], "object");
1445        assert_eq!(json["required"][0], "name");
1446    }
1447}