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