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;
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        // Group operations by feature for representative selection
467        let mut feature_seen: HashMap<&'static str, bool> = HashMap::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                // In default mode, pick one representative per feature
479                // In all-operations mode, test every operation
480                let use_qualified =
481                    self.config.all_operations || feature_seen.contains_key(check_name_base);
482
483                let check_name = if use_qualified {
484                    format!("{}:{}", check_name_base, op.path)
485                } else {
486                    check_name_base.to_string()
487                };
488
489                if !self.config.all_operations {
490                    feature_seen.insert(check_name_base, true);
491                }
492
493                let check = self.build_spec_check(&check_name, op, feature);
494                self.checks.push(check);
495            }
496        }
497
498        self
499    }
500
501    /// Load custom checks from the configured YAML file
502    pub fn with_custom_checks(mut self) -> Result<Self> {
503        let path = match &self.config.custom_checks_file {
504            Some(p) => p.clone(),
505            None => return Ok(self),
506        };
507        let custom_config = CustomConformanceConfig::from_file(&path)?;
508        for check in &custom_config.custom_checks {
509            self.add_custom_check(check);
510        }
511        Ok(self)
512    }
513
514    /// Return the number of checks that will be executed
515    pub fn check_count(&self) -> usize {
516        self.checks.len()
517    }
518
519    /// Execute all checks and return a `ConformanceReport`
520    pub async fn execute(&self) -> Result<ConformanceReport> {
521        let mut results = Vec::with_capacity(self.checks.len());
522
523        for check in &self.checks {
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 _ = tx
538            .send(ConformanceProgress::Started {
539                total_checks: total,
540            })
541            .await;
542
543        let mut results = Vec::with_capacity(total);
544
545        for (i, check) in self.checks.iter().enumerate() {
546            let result = self.execute_check(check).await;
547            let passed = result.passed;
548            let name = result.name.clone();
549            results.push(result);
550
551            let _ = tx
552                .send(ConformanceProgress::CheckCompleted {
553                    name,
554                    passed,
555                    checks_done: i + 1,
556                })
557                .await;
558        }
559
560        let _ = tx.send(ConformanceProgress::Finished).await;
561        Ok(Self::aggregate(results))
562    }
563
564    /// Execute a single check
565    async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
566        let base_url = self.config.effective_base_url();
567        let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
568
569        let mut request = self.client.request(check.method.clone(), &url);
570
571        // Add headers
572        for (name, value) in &check.headers {
573            request = request.header(name.as_str(), value.as_str());
574        }
575
576        // Add body
577        match &check.body {
578            Some(CheckBody::Json(value)) => {
579                request = request.json(value);
580            }
581            Some(CheckBody::FormUrlencoded(fields)) => {
582                request = request.form(fields);
583            }
584            Some(CheckBody::Raw {
585                content,
586                content_type,
587            }) => {
588                // For multipart, use the multipart API
589                if content_type == "text/plain" && check.path.contains("multipart") {
590                    let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
591                        .file_name("test.txt")
592                        .mime_str(content_type)
593                        .unwrap_or_else(|_| {
594                            reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
595                        });
596                    let form = reqwest::multipart::Form::new().part("field", part);
597                    request = request.multipart(form);
598                } else {
599                    request =
600                        request.header("Content-Type", content_type.as_str()).body(content.clone());
601                }
602            }
603            None => {}
604        }
605
606        let response = match request.send().await {
607            Ok(resp) => resp,
608            Err(e) => {
609                return CheckResult {
610                    name: check.name.clone(),
611                    passed: false,
612                    failure_detail: Some(FailureDetail {
613                        check: check.name.clone(),
614                        request: FailureRequest {
615                            method: check.method.to_string(),
616                            url: url.clone(),
617                            headers: HashMap::new(),
618                            body: String::new(),
619                        },
620                        response: FailureResponse {
621                            status: 0,
622                            headers: HashMap::new(),
623                            body: format!("Request failed: {}", e),
624                        },
625                        expected: format!("{:?}", check.validation),
626                    }),
627                };
628            }
629        };
630
631        let status = response.status().as_u16();
632        let resp_headers: HashMap<String, String> = response
633            .headers()
634            .iter()
635            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
636            .collect();
637        let resp_body = response.text().await.unwrap_or_default();
638
639        let passed = self.validate_response(&check.validation, status, &resp_headers, &resp_body);
640
641        let failure_detail = if !passed {
642            Some(FailureDetail {
643                check: check.name.clone(),
644                request: FailureRequest {
645                    method: check.method.to_string(),
646                    url,
647                    headers: check.headers.iter().cloned().collect(),
648                    body: match &check.body {
649                        Some(CheckBody::Json(v)) => v.to_string(),
650                        Some(CheckBody::FormUrlencoded(f)) => f
651                            .iter()
652                            .map(|(k, v)| format!("{}={}", k, v))
653                            .collect::<Vec<_>>()
654                            .join("&"),
655                        Some(CheckBody::Raw { content, .. }) => content.clone(),
656                        None => String::new(),
657                    },
658                },
659                response: FailureResponse {
660                    status,
661                    headers: resp_headers,
662                    body: if resp_body.len() > 500 {
663                        format!("{}...", &resp_body[..500])
664                    } else {
665                        resp_body
666                    },
667                },
668                expected: Self::describe_validation(&check.validation),
669            })
670        } else {
671            None
672        };
673
674        CheckResult {
675            name: check.name.clone(),
676            passed,
677            failure_detail,
678        }
679    }
680
681    /// Validate a response against the check's validation rules
682    fn validate_response(
683        &self,
684        validation: &CheckValidation,
685        status: u16,
686        headers: &HashMap<String, String>,
687        body: &str,
688    ) -> bool {
689        match validation {
690            CheckValidation::StatusRange { min, max_exclusive } => {
691                status >= *min && status < *max_exclusive
692            }
693            CheckValidation::ExactStatus(expected) => status == *expected,
694            CheckValidation::SchemaValidation {
695                status_min,
696                status_max,
697                schema,
698            } => {
699                if status < *status_min || status >= *status_max {
700                    return false;
701                }
702                // Parse body as JSON and validate against schema
703                let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
704                    return false;
705                };
706                jsonschema::is_valid(schema, &body_value)
707            }
708            CheckValidation::Custom {
709                expected_status,
710                expected_headers,
711                expected_body_fields,
712            } => {
713                if status != *expected_status {
714                    return false;
715                }
716                // Check headers with regex
717                for (header_name, pattern) in expected_headers {
718                    let header_val = headers
719                        .get(header_name)
720                        .or_else(|| headers.get(&header_name.to_lowercase()))
721                        .map(|s| s.as_str())
722                        .unwrap_or("");
723                    if let Ok(re) = regex::Regex::new(pattern) {
724                        if !re.is_match(header_val) {
725                            return false;
726                        }
727                    }
728                }
729                // Check body field types
730                if !expected_body_fields.is_empty() {
731                    let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
732                        return false;
733                    };
734                    for (field_name, field_type) in expected_body_fields {
735                        let field = &body_value[field_name];
736                        let ok = match field_type.as_str() {
737                            "string" => field.is_string(),
738                            "integer" => field.is_i64() || field.is_u64(),
739                            "number" => field.is_number(),
740                            "boolean" => field.is_boolean(),
741                            "array" => field.is_array(),
742                            "object" => field.is_object(),
743                            _ => !field.is_null(),
744                        };
745                        if !ok {
746                            return false;
747                        }
748                    }
749                }
750                true
751            }
752        }
753    }
754
755    /// Human-readable validation description for failure reports
756    fn describe_validation(validation: &CheckValidation) -> String {
757        match validation {
758            CheckValidation::StatusRange { min, max_exclusive } => {
759                format!("status >= {} && status < {}", min, max_exclusive)
760            }
761            CheckValidation::ExactStatus(code) => format!("status === {}", code),
762            CheckValidation::SchemaValidation {
763                status_min,
764                status_max,
765                ..
766            } => {
767                format!("status >= {} && status < {} + schema validation", status_min, status_max)
768            }
769            CheckValidation::Custom {
770                expected_status, ..
771            } => {
772                format!("status === {}", expected_status)
773            }
774        }
775    }
776
777    /// Aggregate check results into a `ConformanceReport`
778    fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
779        let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
780        let mut failure_details = Vec::new();
781
782        for result in results {
783            let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
784            if result.passed {
785                entry.0 += 1;
786            } else {
787                entry.1 += 1;
788            }
789            if let Some(detail) = result.failure_detail {
790                failure_details.push(detail);
791            }
792        }
793
794        ConformanceReport::from_results(check_results, failure_details)
795    }
796
797    // --- Helper methods ---
798
799    /// Build a spec-driven check from an annotated operation and feature
800    fn build_spec_check(
801        &self,
802        check_name: &str,
803        op: &AnnotatedOperation,
804        feature: &ConformanceFeature,
805    ) -> ConformanceCheck {
806        // Build URL path with parameters substituted
807        let mut url_path = op.path.clone();
808        for (name, value) in &op.path_params {
809            url_path = url_path.replace(&format!("{{{}}}", name), value);
810        }
811        // Append query params
812        if !op.query_params.is_empty() {
813            let qs: Vec<String> =
814                op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
815            url_path = format!("{}?{}", url_path, qs.join("&"));
816        }
817
818        // Build effective headers
819        let mut effective_headers = self.effective_headers(&op.header_params);
820
821        // For non-default response codes, add mock server header
822        if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
823            let code = match feature {
824                ConformanceFeature::Response400 => "400",
825                ConformanceFeature::Response404 => "404",
826                _ => unreachable!(),
827            };
828            effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
829        }
830
831        // Inject auth headers for security checks or secured endpoints
832        let needs_auth = matches!(
833            feature,
834            ConformanceFeature::SecurityBearer
835                | ConformanceFeature::SecurityBasic
836                | ConformanceFeature::SecurityApiKey
837        ) || !op.security_schemes.is_empty();
838
839        if needs_auth {
840            self.inject_security_headers(&op.security_schemes, &mut effective_headers);
841        }
842
843        // Determine method
844        let method = match op.method.as_str() {
845            "GET" => Method::GET,
846            "POST" => Method::POST,
847            "PUT" => Method::PUT,
848            "PATCH" => Method::PATCH,
849            "DELETE" => Method::DELETE,
850            "HEAD" => Method::HEAD,
851            "OPTIONS" => Method::OPTIONS,
852            _ => Method::GET,
853        };
854
855        // Determine body
856        let body = match method {
857            Method::POST | Method::PUT | Method::PATCH => {
858                if let Some(sample) = &op.sample_body {
859                    // Add Content-Type if not present
860                    let content_type =
861                        op.request_body_content_type.as_deref().unwrap_or("application/json");
862                    if !effective_headers
863                        .iter()
864                        .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
865                    {
866                        effective_headers
867                            .push(("Content-Type".to_string(), content_type.to_string()));
868                    }
869                    match content_type {
870                        "application/x-www-form-urlencoded" => {
871                            // Parse as form fields
872                            let fields: Vec<(String, String)> = serde_json::from_str::<
873                                serde_json::Value,
874                            >(
875                                sample
876                            )
877                            .ok()
878                            .and_then(|v| {
879                                v.as_object().map(|obj| {
880                                    obj.iter()
881                                        .map(|(k, v)| {
882                                            (k.clone(), v.as_str().unwrap_or("").to_string())
883                                        })
884                                        .collect()
885                                })
886                            })
887                            .unwrap_or_default();
888                            Some(CheckBody::FormUrlencoded(fields))
889                        }
890                        _ => {
891                            // Try JSON, fall back to raw
892                            match serde_json::from_str::<serde_json::Value>(sample) {
893                                Ok(v) => Some(CheckBody::Json(v)),
894                                Err(_) => Some(CheckBody::Raw {
895                                    content: sample.clone(),
896                                    content_type: content_type.to_string(),
897                                }),
898                            }
899                        }
900                    }
901                } else {
902                    None
903                }
904            }
905            _ => None,
906        };
907
908        // Determine validation
909        let validation = self.determine_validation(feature, op);
910
911        ConformanceCheck {
912            name: check_name.to_string(),
913            method,
914            path: url_path,
915            headers: effective_headers,
916            body,
917            validation,
918        }
919    }
920
921    /// Determine validation strategy based on the conformance feature
922    fn determine_validation(
923        &self,
924        feature: &ConformanceFeature,
925        op: &AnnotatedOperation,
926    ) -> CheckValidation {
927        match feature {
928            ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
929            ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
930            ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
931            ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
932            ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
933            ConformanceFeature::SecurityBearer
934            | ConformanceFeature::SecurityBasic
935            | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
936                min: 200,
937                max_exclusive: 400,
938            },
939            ConformanceFeature::ResponseValidation => {
940                if let Some(schema) = &op.response_schema {
941                    // Convert openapiv3 Schema to JSON Schema value for jsonschema crate
942                    let schema_json = openapi_schema_to_json_schema(schema);
943                    CheckValidation::SchemaValidation {
944                        status_min: 200,
945                        status_max: 500,
946                        schema: schema_json,
947                    }
948                } else {
949                    CheckValidation::StatusRange {
950                        min: 200,
951                        max_exclusive: 500,
952                    }
953                }
954            }
955            _ => CheckValidation::StatusRange {
956                min: 200,
957                max_exclusive: 500,
958            },
959        }
960    }
961
962    /// Add a simple GET reference check with default status range validation
963    fn add_ref_get(&mut self, name: &str, path: &str) {
964        self.checks.push(ConformanceCheck {
965            name: name.to_string(),
966            method: Method::GET,
967            path: path.to_string(),
968            headers: self.custom_headers_only(),
969            body: None,
970            validation: CheckValidation::StatusRange {
971                min: 200,
972                max_exclusive: 500,
973            },
974        });
975    }
976
977    /// Merge spec-derived headers with custom headers (custom overrides spec)
978    fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
979        let mut headers = Vec::new();
980        for (k, v) in spec_headers {
981            // Skip if custom headers override this one
982            if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
983                continue;
984            }
985            headers.push((k.clone(), v.clone()));
986        }
987        // Append custom headers
988        headers.extend(self.config.custom_headers.clone());
989        headers
990    }
991
992    /// Merge provided headers with custom headers
993    fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
994        for (k, v) in &self.config.custom_headers {
995            if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
996                headers.push((k.clone(), v.clone()));
997            }
998        }
999        headers
1000    }
1001
1002    /// Return only custom headers (for checks that don't have spec-derived headers)
1003    fn custom_headers_only(&self) -> Vec<(String, String)> {
1004        self.config.custom_headers.clone()
1005    }
1006
1007    /// Inject security headers based on resolved security schemes
1008    fn inject_security_headers(
1009        &self,
1010        schemes: &[SecuritySchemeInfo],
1011        headers: &mut Vec<(String, String)>,
1012    ) {
1013        let mut to_add: Vec<(String, String)> = Vec::new();
1014
1015        for scheme in schemes {
1016            match scheme {
1017                SecuritySchemeInfo::Bearer => {
1018                    if !Self::header_present("Authorization", headers, &self.config.custom_headers)
1019                    {
1020                        to_add.push((
1021                            "Authorization".to_string(),
1022                            "Bearer mockforge-conformance-test-token".to_string(),
1023                        ));
1024                    }
1025                }
1026                SecuritySchemeInfo::Basic => {
1027                    if !Self::header_present("Authorization", headers, &self.config.custom_headers)
1028                    {
1029                        let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1030                        use base64::Engine;
1031                        let encoded =
1032                            base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1033                        to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1034                    }
1035                }
1036                SecuritySchemeInfo::ApiKey { location, name } => match location {
1037                    ApiKeyLocation::Header => {
1038                        if !Self::header_present(name, headers, &self.config.custom_headers) {
1039                            let key = self
1040                                .config
1041                                .api_key
1042                                .as_deref()
1043                                .unwrap_or("mockforge-conformance-test-key");
1044                            to_add.push((name.clone(), key.to_string()));
1045                        }
1046                    }
1047                    ApiKeyLocation::Cookie => {
1048                        if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1049                            to_add.push((
1050                                "Cookie".to_string(),
1051                                format!("{}=mockforge-conformance-test-session", name),
1052                            ));
1053                        }
1054                    }
1055                    ApiKeyLocation::Query => {
1056                        // Handled in URL, not headers
1057                    }
1058                },
1059            }
1060        }
1061
1062        headers.extend(to_add);
1063    }
1064
1065    /// Check if a header name is present in either the existing headers or custom headers
1066    fn header_present(
1067        name: &str,
1068        headers: &[(String, String)],
1069        custom_headers: &[(String, String)],
1070    ) -> bool {
1071        headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1072            || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1073    }
1074
1075    /// Add a custom check from YAML config
1076    fn add_custom_check(&mut self, check: &CustomCheck) {
1077        let method = match check.method.to_uppercase().as_str() {
1078            "GET" => Method::GET,
1079            "POST" => Method::POST,
1080            "PUT" => Method::PUT,
1081            "PATCH" => Method::PATCH,
1082            "DELETE" => Method::DELETE,
1083            "HEAD" => Method::HEAD,
1084            "OPTIONS" => Method::OPTIONS,
1085            _ => Method::GET,
1086        };
1087
1088        // Build headers
1089        let mut headers: Vec<(String, String)> =
1090            check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1091        // Add global custom headers (check-specific take priority)
1092        for (k, v) in &self.config.custom_headers {
1093            if !check.headers.contains_key(k) {
1094                headers.push((k.clone(), v.clone()));
1095            }
1096        }
1097        // Add Content-Type for JSON body if not present
1098        if check.body.is_some()
1099            && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1100        {
1101            headers.push(("Content-Type".to_string(), "application/json".to_string()));
1102        }
1103
1104        // Body
1105        let body = check
1106            .body
1107            .as_ref()
1108            .and_then(|b| serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json));
1109
1110        // Build expected headers for validation
1111        let expected_headers: Vec<(String, String)> =
1112            check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1113
1114        // Build expected body fields
1115        let expected_body_fields: Vec<(String, String)> = check
1116            .expected_body_fields
1117            .iter()
1118            .map(|f| (f.name.clone(), f.field_type.clone()))
1119            .collect();
1120
1121        // Primary status check
1122        self.checks.push(ConformanceCheck {
1123            name: check.name.clone(),
1124            method,
1125            path: check.path.clone(),
1126            headers,
1127            body,
1128            validation: CheckValidation::Custom {
1129                expected_status: check.expected_status,
1130                expected_headers,
1131                expected_body_fields,
1132            },
1133        });
1134    }
1135}
1136
1137/// Convert an `openapiv3::Schema` to a JSON Schema `serde_json::Value`
1138/// suitable for use with the `jsonschema` crate.
1139fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1140    use openapiv3::{SchemaKind, Type};
1141
1142    match &schema.schema_kind {
1143        SchemaKind::Type(Type::Object(obj)) => {
1144            let mut props = serde_json::Map::new();
1145            for (name, prop_ref) in &obj.properties {
1146                if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1147                    props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1148                }
1149            }
1150            let mut schema_obj = serde_json::json!({
1151                "type": "object",
1152                "properties": props,
1153            });
1154            if !obj.required.is_empty() {
1155                schema_obj["required"] = serde_json::Value::Array(
1156                    obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1157                );
1158            }
1159            schema_obj
1160        }
1161        SchemaKind::Type(Type::Array(arr)) => {
1162            let mut schema_obj = serde_json::json!({"type": "array"});
1163            if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1164                schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1165            }
1166            schema_obj
1167        }
1168        SchemaKind::Type(Type::String(s)) => {
1169            let mut obj = serde_json::json!({"type": "string"});
1170            if let Some(min) = s.min_length {
1171                obj["minLength"] = serde_json::json!(min);
1172            }
1173            if let Some(max) = s.max_length {
1174                obj["maxLength"] = serde_json::json!(max);
1175            }
1176            if let Some(pattern) = &s.pattern {
1177                obj["pattern"] = serde_json::json!(pattern);
1178            }
1179            if !s.enumeration.is_empty() {
1180                obj["enum"] = serde_json::Value::Array(
1181                    s.enumeration
1182                        .iter()
1183                        .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1184                        .collect(),
1185                );
1186            }
1187            obj
1188        }
1189        SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1190        SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1191        SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1192        _ => serde_json::json!({}),
1193    }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199
1200    #[test]
1201    fn test_reference_check_count() {
1202        let config = ConformanceConfig {
1203            target_url: "http://localhost:3000".to_string(),
1204            ..Default::default()
1205        };
1206        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1207        // 7 params + 3 bodies + 6 schema + 3 composition + 7 formats + 5 constraints
1208        // + 5 response codes + 7 methods + 1 content + 3 security = 47
1209        assert_eq!(executor.check_count(), 47);
1210    }
1211
1212    #[test]
1213    fn test_reference_checks_with_category_filter() {
1214        let config = ConformanceConfig {
1215            target_url: "http://localhost:3000".to_string(),
1216            categories: Some(vec!["Parameters".to_string()]),
1217            ..Default::default()
1218        };
1219        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1220        assert_eq!(executor.check_count(), 7);
1221    }
1222
1223    #[test]
1224    fn test_validate_status_range() {
1225        let config = ConformanceConfig {
1226            target_url: "http://localhost:3000".to_string(),
1227            ..Default::default()
1228        };
1229        let executor = NativeConformanceExecutor::new(config).unwrap();
1230        let headers = HashMap::new();
1231
1232        assert!(executor.validate_response(
1233            &CheckValidation::StatusRange {
1234                min: 200,
1235                max_exclusive: 500,
1236            },
1237            200,
1238            &headers,
1239            "",
1240        ));
1241        assert!(executor.validate_response(
1242            &CheckValidation::StatusRange {
1243                min: 200,
1244                max_exclusive: 500,
1245            },
1246            404,
1247            &headers,
1248            "",
1249        ));
1250        assert!(!executor.validate_response(
1251            &CheckValidation::StatusRange {
1252                min: 200,
1253                max_exclusive: 500,
1254            },
1255            500,
1256            &headers,
1257            "",
1258        ));
1259    }
1260
1261    #[test]
1262    fn test_validate_exact_status() {
1263        let config = ConformanceConfig {
1264            target_url: "http://localhost:3000".to_string(),
1265            ..Default::default()
1266        };
1267        let executor = NativeConformanceExecutor::new(config).unwrap();
1268        let headers = HashMap::new();
1269
1270        assert!(executor.validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "",));
1271        assert!(!executor.validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "",));
1272    }
1273
1274    #[test]
1275    fn test_validate_schema() {
1276        let config = ConformanceConfig {
1277            target_url: "http://localhost:3000".to_string(),
1278            ..Default::default()
1279        };
1280        let executor = NativeConformanceExecutor::new(config).unwrap();
1281        let headers = HashMap::new();
1282
1283        let schema = serde_json::json!({
1284            "type": "object",
1285            "properties": {
1286                "name": {"type": "string"},
1287                "age": {"type": "integer"}
1288            },
1289            "required": ["name"]
1290        });
1291
1292        assert!(executor.validate_response(
1293            &CheckValidation::SchemaValidation {
1294                status_min: 200,
1295                status_max: 300,
1296                schema: schema.clone(),
1297            },
1298            200,
1299            &headers,
1300            r#"{"name": "test", "age": 25}"#,
1301        ));
1302
1303        // Missing required field
1304        assert!(!executor.validate_response(
1305            &CheckValidation::SchemaValidation {
1306                status_min: 200,
1307                status_max: 300,
1308                schema: schema.clone(),
1309            },
1310            200,
1311            &headers,
1312            r#"{"age": 25}"#,
1313        ));
1314    }
1315
1316    #[test]
1317    fn test_validate_custom() {
1318        let config = ConformanceConfig {
1319            target_url: "http://localhost:3000".to_string(),
1320            ..Default::default()
1321        };
1322        let executor = NativeConformanceExecutor::new(config).unwrap();
1323        let mut headers = HashMap::new();
1324        headers.insert("content-type".to_string(), "application/json".to_string());
1325
1326        assert!(executor.validate_response(
1327            &CheckValidation::Custom {
1328                expected_status: 200,
1329                expected_headers: vec![(
1330                    "content-type".to_string(),
1331                    "application/json".to_string(),
1332                )],
1333                expected_body_fields: vec![("name".to_string(), "string".to_string())],
1334            },
1335            200,
1336            &headers,
1337            r#"{"name": "test"}"#,
1338        ));
1339
1340        // Wrong status
1341        assert!(!executor.validate_response(
1342            &CheckValidation::Custom {
1343                expected_status: 200,
1344                expected_headers: vec![],
1345                expected_body_fields: vec![],
1346            },
1347            404,
1348            &headers,
1349            "",
1350        ));
1351    }
1352
1353    #[test]
1354    fn test_aggregate_results() {
1355        let results = vec![
1356            CheckResult {
1357                name: "check1".to_string(),
1358                passed: true,
1359                failure_detail: None,
1360            },
1361            CheckResult {
1362                name: "check2".to_string(),
1363                passed: false,
1364                failure_detail: Some(FailureDetail {
1365                    check: "check2".to_string(),
1366                    request: FailureRequest {
1367                        method: "GET".to_string(),
1368                        url: "http://example.com".to_string(),
1369                        headers: HashMap::new(),
1370                        body: String::new(),
1371                    },
1372                    response: FailureResponse {
1373                        status: 500,
1374                        headers: HashMap::new(),
1375                        body: "error".to_string(),
1376                    },
1377                    expected: "status >= 200 && status < 500".to_string(),
1378                }),
1379            },
1380        ];
1381
1382        let report = NativeConformanceExecutor::aggregate(results);
1383        let raw = report.raw_check_results();
1384        assert_eq!(raw.get("check1"), Some(&(1, 0)));
1385        assert_eq!(raw.get("check2"), Some(&(0, 1)));
1386    }
1387
1388    #[test]
1389    fn test_custom_check_building() {
1390        let config = ConformanceConfig {
1391            target_url: "http://localhost:3000".to_string(),
1392            ..Default::default()
1393        };
1394        let mut executor = NativeConformanceExecutor::new(config).unwrap();
1395
1396        let custom = CustomCheck {
1397            name: "custom:test-get".to_string(),
1398            path: "/api/test".to_string(),
1399            method: "GET".to_string(),
1400            expected_status: 200,
1401            body: None,
1402            expected_headers: std::collections::HashMap::new(),
1403            expected_body_fields: vec![],
1404            headers: std::collections::HashMap::new(),
1405        };
1406
1407        executor.add_custom_check(&custom);
1408        assert_eq!(executor.check_count(), 1);
1409        assert_eq!(executor.checks[0].name, "custom:test-get");
1410    }
1411
1412    #[test]
1413    fn test_openapi_schema_to_json_schema_object() {
1414        use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
1415
1416        let schema = Schema {
1417            schema_data: SchemaData::default(),
1418            schema_kind: SchemaKind::Type(Type::Object(ObjectType {
1419                required: vec!["name".to_string()],
1420                ..Default::default()
1421            })),
1422        };
1423
1424        let json = openapi_schema_to_json_schema(&schema);
1425        assert_eq!(json["type"], "object");
1426        assert_eq!(json["required"][0], "name");
1427    }
1428}