1use 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub struct SchemaViolation {
21 pub field_path: String,
23 pub violation_type: String,
25 pub expected: String,
27 pub actual: String,
29}
30
31#[derive(Debug, Clone)]
33pub struct ConformanceCheck {
34 pub name: String,
36 pub method: Method,
38 pub path: String,
40 pub headers: Vec<(String, String)>,
42 pub body: Option<CheckBody>,
44 pub validation: CheckValidation,
46}
47
48#[derive(Debug, Clone)]
50pub enum CheckBody {
51 Json(serde_json::Value),
53 FormUrlencoded(Vec<(String, String)>),
55 Raw {
57 content: String,
58 content_type: String,
59 },
60 Multipart { parts: Vec<MultipartPart> },
67}
68
69#[derive(Debug, Clone)]
71pub struct MultipartPart {
72 pub bytes: Vec<u8>,
74 pub content_type: String,
77 pub field_name: String,
79 pub filename: String,
81}
82
83#[derive(Debug, Clone, Default)]
88struct ChainMeta {
89 extract: super::custom::ExtractRules,
92 repeat: super::custom::Repeat,
95}
96
97#[derive(Debug, Default, Clone)]
102struct ChainContext {
103 vars: HashMap<String, String>,
104 cookies: HashMap<String, String>,
105}
106
107impl ChainContext {
108 fn substitute(&self, text: &str) -> String {
114 let mut out = String::with_capacity(text.len());
115 let mut rest = text;
116 while let Some(start) = rest.find("${") {
117 out.push_str(&rest[..start]);
118 let after = &rest[start + 2..];
119 if let Some(end) = after.find('}') {
120 let token = &after[..end];
121 let replaced = if let Some(name) = token.strip_prefix("var:") {
122 self.vars.get(name).cloned()
123 } else if let Some(name) = token.strip_prefix("cookie:") {
124 self.cookies.get(name).cloned()
125 } else {
126 token.strip_prefix("header:").and_then(|name| self.vars.get(name).cloned())
130 };
131 if let Some(value) = replaced {
132 out.push_str(&value);
133 } else {
134 out.push_str("${");
137 out.push_str(token);
138 out.push('}');
139 }
140 rest = &after[end + 1..];
141 } else {
142 out.push_str("${");
143 rest = after;
144 break;
145 }
146 }
147 out.push_str(rest);
148 out
149 }
150}
151
152#[derive(Debug, Clone)]
154pub enum CheckValidation {
155 StatusRange { min: u16, max_exclusive: u16 },
157 ExactStatus(u16),
159 SchemaValidation {
161 status_min: u16,
162 status_max: u16,
163 schema: serde_json::Value,
164 },
165 Custom {
167 expected_status: u16,
168 expected_headers: Vec<(String, String)>,
169 expected_body_fields: Vec<(String, String)>,
170 },
171}
172
173#[derive(Debug, Clone, serde::Serialize)]
175#[serde(tag = "type")]
176pub enum ConformanceProgress {
177 #[serde(rename = "started")]
179 Started { total_checks: usize },
180 #[serde(rename = "check_completed")]
182 CheckCompleted {
183 name: String,
184 passed: bool,
185 checks_done: usize,
186 },
187 #[serde(rename = "finished")]
189 Finished,
190 #[serde(rename = "error")]
192 Error { message: String },
193}
194
195#[derive(Debug)]
197struct CheckResult {
198 name: String,
199 passed: bool,
200 failure_detail: Option<FailureDetail>,
201 captured: Option<CapturedExchange>,
204}
205
206#[derive(Debug, serde::Serialize)]
208struct CapturedExchange {
209 method: String,
210 url: String,
211 request_headers: HashMap<String, String>,
212 request_body: String,
213 response_status: u16,
214 response_headers: HashMap<String, String>,
215 response_body: String,
216}
217
218pub struct NativeConformanceExecutor {
220 config: ConformanceConfig,
221 client: Client,
222 checks: Vec<ConformanceCheck>,
223 chain_meta: HashMap<usize, ChainMeta>,
228 chain_iterations: u32,
233 network_events: std::sync::Mutex<Vec<NetworkEvent>>,
240}
241
242#[derive(Debug, Clone, serde::Serialize)]
247pub struct NetworkEvent {
248 pub timestamp: chrono::DateTime<chrono::Utc>,
250 pub check: String,
252 pub method: String,
254 pub url: String,
256 pub kind: String,
259 pub message: String,
261}
262
263impl NativeConformanceExecutor {
264 pub fn new(config: ConformanceConfig) -> Result<Self> {
266 let mut builder = Client::builder()
267 .timeout(Duration::from_secs(30))
268 .connect_timeout(Duration::from_secs(10));
269
270 if config.skip_tls_verify {
271 builder = builder.danger_accept_invalid_certs(true);
272 }
273
274 let client = builder
275 .build()
276 .map_err(|e| BenchError::Other(format!("Failed to build HTTP client: {}", e)))?;
277
278 Ok(Self {
279 config,
280 client,
281 checks: Vec::new(),
282 chain_meta: HashMap::new(),
283 chain_iterations: 1,
284 network_events: std::sync::Mutex::new(Vec::new()),
285 })
286 }
287
288 fn record_network_event(&self, ev: NetworkEvent) {
293 if let Ok(mut guard) = self.network_events.lock() {
294 guard.push(ev);
295 }
296 }
297
298 #[must_use]
301 pub fn with_reference_checks(mut self) -> Self {
302 if self.config.should_include_category("Parameters") {
304 self.add_ref_get("param:path:string", "/conformance/params/hello");
305 self.add_ref_get("param:path:integer", "/conformance/params/42");
306 self.add_ref_get("param:query:string", "/conformance/params/query?name=test");
307 self.add_ref_get("param:query:integer", "/conformance/params/query?count=10");
308 self.add_ref_get("param:query:array", "/conformance/params/query?tags=a&tags=b");
309 self.checks.push(ConformanceCheck {
310 name: "param:header".to_string(),
311 method: Method::GET,
312 path: "/conformance/params/header".to_string(),
313 headers: self
314 .merge_headers(vec![("X-Custom-Param".to_string(), "test-value".to_string())]),
315 body: None,
316 validation: CheckValidation::StatusRange {
317 min: 200,
318 max_exclusive: 500,
319 },
320 });
321 self.checks.push(ConformanceCheck {
322 name: "param:cookie".to_string(),
323 method: Method::GET,
324 path: "/conformance/params/cookie".to_string(),
325 headers: self
326 .merge_headers(vec![("Cookie".to_string(), "session=abc123".to_string())]),
327 body: None,
328 validation: CheckValidation::StatusRange {
329 min: 200,
330 max_exclusive: 500,
331 },
332 });
333 }
334
335 if self.config.should_include_category("Request Bodies") {
337 self.checks.push(ConformanceCheck {
338 name: "body:json".to_string(),
339 method: Method::POST,
340 path: "/conformance/body/json".to_string(),
341 headers: self.merge_headers(vec![(
342 "Content-Type".to_string(),
343 "application/json".to_string(),
344 )]),
345 body: Some(CheckBody::Json(serde_json::json!({"name": "test", "value": 42}))),
346 validation: CheckValidation::StatusRange {
347 min: 200,
348 max_exclusive: 500,
349 },
350 });
351 self.checks.push(ConformanceCheck {
352 name: "body:form-urlencoded".to_string(),
353 method: Method::POST,
354 path: "/conformance/body/form".to_string(),
355 headers: self.custom_headers_only(),
356 body: Some(CheckBody::FormUrlencoded(vec![
357 ("field1".to_string(), "value1".to_string()),
358 ("field2".to_string(), "value2".to_string()),
359 ])),
360 validation: CheckValidation::StatusRange {
361 min: 200,
362 max_exclusive: 500,
363 },
364 });
365 self.checks.push(ConformanceCheck {
366 name: "body:multipart".to_string(),
367 method: Method::POST,
368 path: "/conformance/body/multipart".to_string(),
369 headers: self.custom_headers_only(),
370 body: Some(CheckBody::Raw {
371 content: "test content".to_string(),
372 content_type: "text/plain".to_string(),
373 }),
374 validation: CheckValidation::StatusRange {
375 min: 200,
376 max_exclusive: 500,
377 },
378 });
379 }
380
381 if self.config.should_include_category("Schema Types") {
383 let types = [
384 ("string", r#"{"value": "hello"}"#, "schema:string"),
385 ("integer", r#"{"value": 42}"#, "schema:integer"),
386 ("number", r#"{"value": 3.14}"#, "schema:number"),
387 ("boolean", r#"{"value": true}"#, "schema:boolean"),
388 ("array", r#"{"value": [1, 2, 3]}"#, "schema:array"),
389 ("object", r#"{"value": {"nested": "data"}}"#, "schema:object"),
390 ];
391 for (type_name, body_str, check_name) in types {
392 self.checks.push(ConformanceCheck {
393 name: check_name.to_string(),
394 method: Method::POST,
395 path: format!("/conformance/schema/{}", type_name),
396 headers: self.merge_headers(vec![(
397 "Content-Type".to_string(),
398 "application/json".to_string(),
399 )]),
400 body: Some(CheckBody::Json(
401 serde_json::from_str(body_str).expect("valid JSON"),
402 )),
403 validation: CheckValidation::StatusRange {
404 min: 200,
405 max_exclusive: 500,
406 },
407 });
408 }
409 }
410
411 if self.config.should_include_category("Composition") {
413 let compositions = [
414 ("oneOf", r#"{"type": "string", "value": "test"}"#, "composition:oneOf"),
415 ("anyOf", r#"{"value": "test"}"#, "composition:anyOf"),
416 ("allOf", r#"{"name": "test", "id": 1}"#, "composition:allOf"),
417 ];
418 for (kind, body_str, check_name) in compositions {
419 self.checks.push(ConformanceCheck {
420 name: check_name.to_string(),
421 method: Method::POST,
422 path: format!("/conformance/composition/{}", kind),
423 headers: self.merge_headers(vec![(
424 "Content-Type".to_string(),
425 "application/json".to_string(),
426 )]),
427 body: Some(CheckBody::Json(
428 serde_json::from_str(body_str).expect("valid JSON"),
429 )),
430 validation: CheckValidation::StatusRange {
431 min: 200,
432 max_exclusive: 500,
433 },
434 });
435 }
436 }
437
438 if self.config.should_include_category("String Formats") {
440 let formats = [
441 ("date", r#"{"value": "2024-01-15"}"#, "format:date"),
442 ("date-time", r#"{"value": "2024-01-15T10:30:00Z"}"#, "format:date-time"),
443 ("email", r#"{"value": "test@example.com"}"#, "format:email"),
444 ("uuid", r#"{"value": "550e8400-e29b-41d4-a716-446655440000"}"#, "format:uuid"),
445 ("uri", r#"{"value": "https://example.com/path"}"#, "format:uri"),
446 ("ipv4", r#"{"value": "192.168.1.1"}"#, "format:ipv4"),
447 ("ipv6", r#"{"value": "::1"}"#, "format:ipv6"),
448 ];
449 for (fmt, body_str, check_name) in formats {
450 self.checks.push(ConformanceCheck {
451 name: check_name.to_string(),
452 method: Method::POST,
453 path: format!("/conformance/formats/{}", fmt),
454 headers: self.merge_headers(vec![(
455 "Content-Type".to_string(),
456 "application/json".to_string(),
457 )]),
458 body: Some(CheckBody::Json(
459 serde_json::from_str(body_str).expect("valid JSON"),
460 )),
461 validation: CheckValidation::StatusRange {
462 min: 200,
463 max_exclusive: 500,
464 },
465 });
466 }
467 }
468
469 if self.config.should_include_category("Constraints") {
471 let constraints = [
472 ("required", r#"{"required_field": "present"}"#, "constraint:required"),
473 ("optional", r#"{}"#, "constraint:optional"),
474 ("minmax", r#"{"value": 50}"#, "constraint:minmax"),
475 ("pattern", r#"{"value": "ABC-123"}"#, "constraint:pattern"),
476 ("enum", r#"{"status": "active"}"#, "constraint:enum"),
477 ];
478 for (kind, body_str, check_name) in constraints {
479 self.checks.push(ConformanceCheck {
480 name: check_name.to_string(),
481 method: Method::POST,
482 path: format!("/conformance/constraints/{}", kind),
483 headers: self.merge_headers(vec![(
484 "Content-Type".to_string(),
485 "application/json".to_string(),
486 )]),
487 body: Some(CheckBody::Json(
488 serde_json::from_str(body_str).expect("valid JSON"),
489 )),
490 validation: CheckValidation::StatusRange {
491 min: 200,
492 max_exclusive: 500,
493 },
494 });
495 }
496 }
497
498 if self.config.should_include_category("Response Codes") {
500 for (code_str, check_name) in [
501 ("200", "response:200"),
502 ("201", "response:201"),
503 ("204", "response:204"),
504 ("400", "response:400"),
505 ("404", "response:404"),
506 ] {
507 let code: u16 = code_str.parse().unwrap();
508 self.checks.push(ConformanceCheck {
509 name: check_name.to_string(),
510 method: Method::GET,
511 path: format!("/conformance/responses/{}", code_str),
512 headers: self.custom_headers_only(),
513 body: None,
514 validation: CheckValidation::ExactStatus(code),
515 });
516 }
517 }
518
519 if self.config.should_include_category("HTTP Methods") {
521 self.add_ref_get("method:GET", "/conformance/methods");
522 for (method, check_name) in [
523 (Method::POST, "method:POST"),
524 (Method::PUT, "method:PUT"),
525 (Method::PATCH, "method:PATCH"),
526 ] {
527 self.checks.push(ConformanceCheck {
528 name: check_name.to_string(),
529 method,
530 path: "/conformance/methods".to_string(),
531 headers: self.merge_headers(vec![(
532 "Content-Type".to_string(),
533 "application/json".to_string(),
534 )]),
535 body: Some(CheckBody::Json(serde_json::json!({"action": "test"}))),
536 validation: CheckValidation::StatusRange {
537 min: 200,
538 max_exclusive: 500,
539 },
540 });
541 }
542 for (method, check_name) in [
543 (Method::DELETE, "method:DELETE"),
544 (Method::HEAD, "method:HEAD"),
545 (Method::OPTIONS, "method:OPTIONS"),
546 ] {
547 self.checks.push(ConformanceCheck {
548 name: check_name.to_string(),
549 method,
550 path: "/conformance/methods".to_string(),
551 headers: self.custom_headers_only(),
552 body: None,
553 validation: CheckValidation::StatusRange {
554 min: 200,
555 max_exclusive: 500,
556 },
557 });
558 }
559 }
560
561 if self.config.should_include_category("Content Types") {
563 self.checks.push(ConformanceCheck {
564 name: "content:negotiation".to_string(),
565 method: Method::GET,
566 path: "/conformance/content-types".to_string(),
567 headers: self
568 .merge_headers(vec![("Accept".to_string(), "application/json".to_string())]),
569 body: None,
570 validation: CheckValidation::StatusRange {
571 min: 200,
572 max_exclusive: 500,
573 },
574 });
575 }
576
577 if self.config.should_include_category("Security") {
579 self.checks.push(ConformanceCheck {
581 name: "security:bearer".to_string(),
582 method: Method::GET,
583 path: "/conformance/security/bearer".to_string(),
584 headers: self.merge_headers(vec![(
585 "Authorization".to_string(),
586 "Bearer test-token-123".to_string(),
587 )]),
588 body: None,
589 validation: CheckValidation::StatusRange {
590 min: 200,
591 max_exclusive: 500,
592 },
593 });
594
595 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
597 self.checks.push(ConformanceCheck {
598 name: "security:apikey".to_string(),
599 method: Method::GET,
600 path: "/conformance/security/apikey".to_string(),
601 headers: self.merge_headers(vec![("X-API-Key".to_string(), api_key.to_string())]),
602 body: None,
603 validation: CheckValidation::StatusRange {
604 min: 200,
605 max_exclusive: 500,
606 },
607 });
608
609 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
611 use base64::Engine;
612 let encoded = base64::engine::general_purpose::STANDARD.encode(basic_creds.as_bytes());
613 self.checks.push(ConformanceCheck {
614 name: "security:basic".to_string(),
615 method: Method::GET,
616 path: "/conformance/security/basic".to_string(),
617 headers: self.merge_headers(vec![(
618 "Authorization".to_string(),
619 format!("Basic {}", encoded),
620 )]),
621 body: None,
622 validation: CheckValidation::StatusRange {
623 min: 200,
624 max_exclusive: 500,
625 },
626 });
627 }
628
629 self
630 }
631
632 #[must_use]
634 pub fn with_spec_driven_checks(mut self, operations: &[AnnotatedOperation]) -> Self {
635 let mut feature_seen: HashSet<&'static str> = HashSet::new();
637
638 for op in operations {
639 for feature in &op.features {
640 let category = feature.category();
641 if !self.config.should_include_category(category) {
642 continue;
643 }
644
645 let check_name_base = feature.check_name();
646
647 if self.config.all_operations {
648 let check_name = format!("{}:{}", check_name_base, op.path);
650 let check = self.build_spec_check(&check_name, op, feature);
651 self.checks.push(check);
652 } else {
653 if feature_seen.insert(check_name_base) {
655 let check_name = format!("{}:{}", check_name_base, op.path);
656 let check = self.build_spec_check(&check_name, op, feature);
657 self.checks.push(check);
658 }
659 }
660 }
661 }
662
663 self
664 }
665
666 pub fn with_custom_checks(self) -> Result<Self> {
668 let path = match &self.config.custom_checks_file {
669 Some(p) => p.clone(),
670 None => return Ok(self),
671 };
672 let custom_config = CustomConformanceConfig::from_file(&path)?;
673 self.append_custom_checks(&custom_config)
674 }
675
676 pub fn with_custom_checks_from_config(
684 self,
685 custom_config: CustomConformanceConfig,
686 ) -> Result<Self> {
687 self.append_custom_checks(&custom_config)
688 }
689
690 fn append_custom_checks(mut self, custom_config: &CustomConformanceConfig) -> Result<Self> {
695 let filter_re = match &self.config.custom_filter {
696 Some(pattern) => Some(regex::Regex::new(pattern).map_err(|e| {
697 BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
698 })?),
699 None => None,
700 };
701
702 let mut included = 0usize;
703 let total = custom_config.custom_checks.len();
704 self.chain_iterations = custom_config.chain_iterations.max(1);
708 for check in &custom_config.custom_checks {
709 if let Some(ref re) = filter_re {
710 if !re.is_match(&check.name) && !re.is_match(&check.path) {
711 continue;
712 }
713 }
714 self.add_custom_check(check);
715 included += 1;
716 }
717
718 if filter_re.is_some() {
719 tracing::info!("Custom check filter: {}/{} checks matched pattern", included, total);
720 }
721
722 Ok(self)
723 }
724
725 pub fn check_count(&self) -> usize {
727 self.checks.len()
728 }
729
730 pub async fn execute(&self) -> Result<ConformanceReport> {
732 let chain_iters = self.chain_iterations.max(1);
733 let mut results = Vec::with_capacity(self.checks.len() * chain_iters as usize);
734 let delay = self.config.request_delay_ms;
735
736 for _iter in 0..chain_iters {
737 let mut ctx = ChainContext::default();
742 for (i, check) in self.checks.iter().enumerate() {
743 if delay > 0 && i > 0 {
744 tokio::time::sleep(Duration::from_millis(delay)).await;
745 }
746 if let Some(meta) = self.chain_meta.get(&i).cloned() {
747 results.extend(self.execute_chain_check(check, &meta, &mut ctx).await);
748 } else {
749 results.push(self.execute_check(check).await);
750 }
751 }
752 }
753
754 if self.config.export_requests {
756 if let Some(ref output_dir) = self.config.output_dir {
757 let request_log: Vec<_> = results
758 .iter()
759 .filter_map(|r| {
760 r.captured.as_ref().map(|c| {
761 serde_json::json!({
762 "check": r.name,
763 "passed": r.passed,
764 "request": {
765 "method": c.method,
766 "url": c.url,
767 "headers": c.request_headers,
768 "body": c.request_body,
769 },
770 "response": {
771 "status": c.response_status,
772 "headers": c.response_headers,
773 "body": c.response_body,
774 },
775 })
776 })
777 })
778 .collect();
779 let path = output_dir.join("conformance-requests.json");
780 if let Ok(json) = serde_json::to_string_pretty(&request_log) {
781 let _ = std::fs::write(&path, json);
782 tracing::info!(
783 "Exported {} request/response pairs to {}",
784 request_log.len(),
785 path.display()
786 );
787 }
788 }
789 }
790
791 if let Some(ref output_dir) = self.config.output_dir {
797 if let Ok(guard) = self.network_events.lock() {
798 if !guard.is_empty() {
799 let path = output_dir.join("conformance-network-events.json");
800 if let Ok(json) = serde_json::to_string_pretty(&*guard) {
801 let _ = std::fs::write(&path, json);
802 tracing::warn!(
803 "Recorded {} wire-level network event(s) to {} — see file for timestamps and classified reasons",
804 guard.len(),
805 path.display()
806 );
807 }
808 }
809 }
810 }
811
812 Ok(Self::aggregate(results))
813 }
814
815 pub async fn execute_with_progress(
817 &self,
818 tx: mpsc::Sender<ConformanceProgress>,
819 ) -> Result<ConformanceReport> {
820 let chain_iters = self.chain_iterations.max(1);
821 let total = self.checks.len() * chain_iters as usize;
822 let delay = self.config.request_delay_ms;
823 let _ = tx
824 .send(ConformanceProgress::Started {
825 total_checks: total,
826 })
827 .await;
828
829 let mut results = Vec::with_capacity(total);
830
831 for _iter in 0..chain_iters {
832 let mut ctx = ChainContext::default();
833 for (i, check) in self.checks.iter().enumerate() {
834 if delay > 0 && i > 0 {
835 tokio::time::sleep(Duration::from_millis(delay)).await;
836 }
837 let new_results = if let Some(meta) = self.chain_meta.get(&i).cloned() {
838 self.execute_chain_check(check, &meta, &mut ctx).await
839 } else {
840 vec![self.execute_check(check).await]
841 };
842 for result in new_results {
843 let passed = result.passed;
844 let name = result.name.clone();
845 results.push(result);
846 let _ = tx
847 .send(ConformanceProgress::CheckCompleted {
848 name,
849 passed,
850 checks_done: results.len(),
851 })
852 .await;
853 }
854 }
855 }
856
857 let _ = tx.send(ConformanceProgress::Finished).await;
858 Ok(Self::aggregate(results))
859 }
860
861 async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
863 let base_url = self.config.effective_base_url();
864 let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
865
866 let mut request = self.client.request(check.method.clone(), &url);
867
868 for (name, value) in &check.headers {
870 request = request.header(name.as_str(), value.as_str());
871 }
872
873 match &check.body {
875 Some(CheckBody::Json(value)) => {
876 request = request.json(value);
877 }
878 Some(CheckBody::FormUrlencoded(fields)) => {
879 request = request.form(fields);
880 }
881 Some(CheckBody::Raw {
882 content,
883 content_type,
884 }) => {
885 if content_type == "text/plain" && check.path.contains("multipart") {
887 let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
888 .file_name("test.txt")
889 .mime_str(content_type)
890 .unwrap_or_else(|_| {
891 reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
892 });
893 let form = reqwest::multipart::Form::new().part("field", part);
894 request = request.multipart(form);
895 } else {
896 request =
897 request.header("Content-Type", content_type.as_str()).body(content.clone());
898 }
899 }
900 Some(CheckBody::Multipart { parts }) => {
907 let mut form = reqwest::multipart::Form::new();
908 for part_spec in parts {
909 let mut part = reqwest::multipart::Part::bytes(part_spec.bytes.clone())
910 .file_name(part_spec.filename.clone());
911 part = match part.mime_str(&part_spec.content_type) {
912 Ok(p) => p,
913 Err(_) => reqwest::multipart::Part::bytes(part_spec.bytes.clone())
914 .file_name(part_spec.filename.clone())
915 .mime_str("application/octet-stream")
916 .expect("application/octet-stream is a valid MIME type"),
917 };
918 form = form.part(part_spec.field_name.clone(), part);
919 }
920 request = request.multipart(form);
921 }
922 None => {}
923 }
924
925 let req_body_str = match &check.body {
926 Some(CheckBody::Json(v)) => v.to_string(),
927 Some(CheckBody::FormUrlencoded(f)) => {
928 f.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&")
929 }
930 Some(CheckBody::Raw { content, .. }) => content.clone(),
931 Some(CheckBody::Multipart { parts }) => {
938 let summary: Vec<String> = parts
939 .iter()
940 .map(|p| {
941 format!("{} ({}, {} bytes)", p.filename, p.content_type, p.bytes.len())
942 })
943 .collect();
944 format!("<{} file(s): {}>", parts.len(), summary.join(", "))
945 }
946 None => String::new(),
947 };
948
949 let response = match request.send().await {
950 Ok(resp) => resp,
951 Err(e) => {
952 let kind = if e.is_connect() {
963 "connect"
964 } else if e.is_timeout() {
965 "timeout"
966 } else if e.is_request() {
967 "request"
968 } else if e.is_body() {
969 "body"
970 } else if e.is_decode() {
971 "decode"
972 } else if format!("{}", e).to_ascii_lowercase().contains("tls") {
973 "tls"
974 } else {
975 "other"
976 };
977 self.record_network_event(NetworkEvent {
978 timestamp: chrono::Utc::now(),
979 check: check.name.clone(),
980 method: check.method.to_string(),
981 url: url.clone(),
982 kind: kind.to_string(),
983 message: format!("{}", e),
984 });
985 return CheckResult {
986 name: check.name.clone(),
987 passed: false,
988 failure_detail: Some(FailureDetail {
989 check: check.name.clone(),
990 request: FailureRequest {
991 method: check.method.to_string(),
992 url: url.clone(),
993 headers: HashMap::new(),
994 body: String::new(),
995 },
996 response: FailureResponse {
997 status: 0,
998 headers: HashMap::new(),
999 body: format!("Request failed ({}): {}", kind, e),
1000 },
1001 expected: format!("{:?}", check.validation),
1002 schema_violations: Vec::new(),
1003 }),
1004 captured: None,
1005 };
1006 }
1007 };
1008
1009 let status = response.status().as_u16();
1010 let resp_headers: HashMap<String, String> = response
1011 .headers()
1012 .iter()
1013 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
1014 .collect();
1015 let resp_body = response.text().await.unwrap_or_default();
1016
1017 let (passed, schema_violations) =
1018 self.validate_response(&check.validation, status, &resp_headers, &resp_body);
1019
1020 let need_capture = self.config.export_requests || !self.chain_meta.is_empty();
1027 let captured = if need_capture {
1028 Some(CapturedExchange {
1029 method: check.method.to_string(),
1030 url: url.clone(),
1031 request_headers: check.headers.iter().cloned().collect(),
1032 request_body: if req_body_str.len() > 65536 {
1040 format!(
1041 "{}\n<truncated at 65536 bytes; full body was {} bytes>",
1042 &req_body_str[..65536],
1043 req_body_str.len()
1044 )
1045 } else {
1046 req_body_str
1047 },
1048 response_status: status,
1049 response_headers: resp_headers.clone(),
1050 response_body: if resp_body.len() > 65536 {
1051 format!(
1052 "{}\n<truncated at 65536 bytes; full body was {} bytes>",
1053 &resp_body[..65536],
1054 resp_body.len()
1055 )
1056 } else {
1057 resp_body.clone()
1058 },
1059 })
1060 } else {
1061 None
1062 };
1063
1064 let failure_detail = if !passed {
1065 Some(FailureDetail {
1066 check: check.name.clone(),
1067 request: FailureRequest {
1068 method: check.method.to_string(),
1069 url,
1070 headers: check.headers.iter().cloned().collect(),
1071 body: match &check.body {
1072 Some(CheckBody::Json(v)) => v.to_string(),
1073 Some(CheckBody::FormUrlencoded(f)) => f
1074 .iter()
1075 .map(|(k, v)| format!("{}={}", k, v))
1076 .collect::<Vec<_>>()
1077 .join("&"),
1078 Some(CheckBody::Raw { content, .. }) => content.clone(),
1079 Some(CheckBody::Multipart { parts }) => {
1083 format!("<{} multipart file(s)>", parts.len())
1084 }
1085 None => String::new(),
1086 },
1087 },
1088 response: FailureResponse {
1089 status,
1090 headers: resp_headers,
1091 body: if resp_body.len() > 500 {
1092 format!("{}...", &resp_body[..500])
1093 } else {
1094 resp_body
1095 },
1096 },
1097 expected: Self::describe_validation(&check.validation),
1098 schema_violations,
1099 })
1100 } else {
1101 None
1102 };
1103
1104 CheckResult {
1105 name: check.name.clone(),
1106 passed,
1107 failure_detail,
1108 captured,
1109 }
1110 }
1111
1112 async fn execute_chain_check(
1124 &self,
1125 check: &ConformanceCheck,
1126 meta: &ChainMeta,
1127 ctx: &mut ChainContext,
1128 ) -> Vec<CheckResult> {
1129 let substituted = apply_chain_context(check, ctx);
1130 let count = meta.repeat.count.max(1);
1131 let results = match meta.repeat.mode {
1132 super::custom::RepeatMode::Sequential => {
1133 let mut out = Vec::with_capacity(count as usize);
1134 for _ in 0..count {
1135 out.push(self.execute_check(&substituted).await);
1136 }
1137 out
1138 }
1139 super::custom::RepeatMode::Parallel => {
1140 let futs = (0..count).map(|_| self.execute_check(&substituted));
1141 futures::future::join_all(futs).await
1142 }
1143 };
1144
1145 if !meta.extract.is_empty() {
1151 if let Some(first) = results.first() {
1152 if let Some(captured) = &first.captured {
1153 extract_into_context(
1154 &meta.extract,
1155 &captured.response_headers,
1156 &captured.response_body,
1157 ctx,
1158 );
1159 }
1160 }
1161 }
1162 results
1163 }
1164
1165 fn validate_response(
1170 &self,
1171 validation: &CheckValidation,
1172 status: u16,
1173 headers: &HashMap<String, String>,
1174 body: &str,
1175 ) -> (bool, Vec<SchemaViolation>) {
1176 match validation {
1177 CheckValidation::StatusRange { min, max_exclusive } => {
1178 (status >= *min && status < *max_exclusive, Vec::new())
1179 }
1180 CheckValidation::ExactStatus(expected) => (status == *expected, Vec::new()),
1181 CheckValidation::SchemaValidation {
1182 status_min,
1183 status_max,
1184 schema,
1185 } => {
1186 if status < *status_min || status >= *status_max {
1187 return (false, Vec::new());
1188 }
1189 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1191 return (
1192 false,
1193 vec![SchemaViolation {
1194 field_path: "/".to_string(),
1195 violation_type: "parse_error".to_string(),
1196 expected: "valid JSON".to_string(),
1197 actual: "non-JSON response body".to_string(),
1198 }],
1199 );
1200 };
1201 match jsonschema::validator_for(schema) {
1202 Ok(validator) => {
1203 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
1204 if errors.is_empty() {
1205 (true, Vec::new())
1206 } else {
1207 let violations = errors
1208 .iter()
1209 .map(|err| {
1210 let field_path = err.instance_path.to_string();
1211 let field_path = if field_path.is_empty() {
1212 "/".to_string()
1213 } else {
1214 field_path
1215 };
1216 SchemaViolation {
1217 field_path,
1218 violation_type: format!("{:?}", err.kind)
1219 .split('(')
1220 .next()
1221 .unwrap_or("unknown")
1222 .split('{')
1223 .next()
1224 .unwrap_or("unknown")
1225 .split(' ')
1226 .next()
1227 .unwrap_or("unknown")
1228 .trim()
1229 .to_string(),
1230 expected: {
1231 let schema_str = format!("{}", err.schema_path);
1235 match &err.kind {
1236 jsonschema::error::ValidationErrorKind::Type { kind } => {
1237 format!("type: {:?}", kind)
1238 }
1239 jsonschema::error::ValidationErrorKind::Required { property } => {
1240 format!("required field: {}", property)
1241 }
1242 _ => {
1243 schema_str
1245 .rsplit('/')
1246 .next()
1247 .unwrap_or(&schema_str)
1248 .to_string()
1249 }
1250 }
1251 },
1252 actual: format!("{}", err),
1253 }
1254 })
1255 .collect();
1256 (false, violations)
1257 }
1258 }
1259 Err(_) => {
1260 (
1262 false,
1263 vec![SchemaViolation {
1264 field_path: "/".to_string(),
1265 violation_type: "schema_compile_error".to_string(),
1266 expected: "valid JSON schema".to_string(),
1267 actual: "schema failed to compile".to_string(),
1268 }],
1269 )
1270 }
1271 }
1272 }
1273 CheckValidation::Custom {
1274 expected_status,
1275 expected_headers,
1276 expected_body_fields,
1277 } => {
1278 if status != *expected_status {
1279 return (false, Vec::new());
1280 }
1281 for (header_name, pattern) in expected_headers {
1283 let header_val = headers
1284 .get(header_name)
1285 .or_else(|| headers.get(&header_name.to_lowercase()))
1286 .map(|s| s.as_str())
1287 .unwrap_or("");
1288 if let Ok(re) = regex::Regex::new(pattern) {
1289 if !re.is_match(header_val) {
1290 return (false, Vec::new());
1291 }
1292 }
1293 }
1294 if !expected_body_fields.is_empty() {
1296 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1297 return (false, Vec::new());
1298 };
1299 for (field_name, field_type) in expected_body_fields {
1300 let field = &body_value[field_name];
1301 let ok = match field_type.as_str() {
1302 "string" => field.is_string(),
1303 "integer" => field.is_i64() || field.is_u64(),
1304 "number" => field.is_number(),
1305 "boolean" => field.is_boolean(),
1306 "array" => field.is_array(),
1307 "object" => field.is_object(),
1308 _ => !field.is_null(),
1309 };
1310 if !ok {
1311 return (false, Vec::new());
1312 }
1313 }
1314 }
1315 (true, Vec::new())
1316 }
1317 }
1318 }
1319
1320 fn describe_validation(validation: &CheckValidation) -> String {
1322 match validation {
1323 CheckValidation::StatusRange { min, max_exclusive } => {
1324 format!("status >= {} && status < {}", min, max_exclusive)
1325 }
1326 CheckValidation::ExactStatus(code) => format!("status === {}", code),
1327 CheckValidation::SchemaValidation {
1328 status_min,
1329 status_max,
1330 ..
1331 } => {
1332 format!("status >= {} && status < {} + schema validation", status_min, status_max)
1333 }
1334 CheckValidation::Custom {
1335 expected_status, ..
1336 } => {
1337 format!("status === {}", expected_status)
1338 }
1339 }
1340 }
1341
1342 fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
1344 let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
1345 let mut failure_details = Vec::new();
1346
1347 for result in results {
1348 let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
1349 if result.passed {
1350 entry.0 += 1;
1351 } else {
1352 entry.1 += 1;
1353 }
1354 if let Some(detail) = result.failure_detail {
1355 failure_details.push(detail);
1356 }
1357 }
1358
1359 ConformanceReport::from_results(check_results, failure_details)
1360 }
1361
1362 fn build_spec_check(
1366 &self,
1367 check_name: &str,
1368 op: &AnnotatedOperation,
1369 feature: &ConformanceFeature,
1370 ) -> ConformanceCheck {
1371 let mut url_path = op.path.clone();
1373 for (name, value) in &op.path_params {
1374 url_path = url_path.replace(&format!("{{{}}}", name), value);
1375 }
1376 if !op.query_params.is_empty() {
1378 let qs: Vec<String> =
1379 op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1380 url_path = format!("{}?{}", url_path, qs.join("&"));
1381 }
1382
1383 let mut effective_headers = self.effective_headers(&op.header_params);
1385
1386 if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
1388 let code = match feature {
1389 ConformanceFeature::Response400 => "400",
1390 ConformanceFeature::Response404 => "404",
1391 _ => unreachable!(),
1392 };
1393 effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
1394 }
1395
1396 let needs_auth = matches!(
1398 feature,
1399 ConformanceFeature::SecurityBearer
1400 | ConformanceFeature::SecurityBasic
1401 | ConformanceFeature::SecurityApiKey
1402 ) || !op.security_schemes.is_empty();
1403
1404 if needs_auth {
1405 self.inject_security_headers(&op.security_schemes, &mut effective_headers);
1406 }
1407
1408 let method = match op.method.as_str() {
1410 "GET" => Method::GET,
1411 "POST" => Method::POST,
1412 "PUT" => Method::PUT,
1413 "PATCH" => Method::PATCH,
1414 "DELETE" => Method::DELETE,
1415 "HEAD" => Method::HEAD,
1416 "OPTIONS" => Method::OPTIONS,
1417 _ => Method::GET,
1418 };
1419
1420 let body = match method {
1422 Method::POST | Method::PUT | Method::PATCH => {
1423 if let Some(sample) = &op.sample_body {
1424 let content_type =
1426 op.request_body_content_type.as_deref().unwrap_or("application/json");
1427 if !effective_headers
1428 .iter()
1429 .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1430 {
1431 effective_headers
1432 .push(("Content-Type".to_string(), content_type.to_string()));
1433 }
1434 match content_type {
1435 "application/x-www-form-urlencoded" => {
1436 let fields: Vec<(String, String)> = serde_json::from_str::<
1438 serde_json::Value,
1439 >(
1440 sample
1441 )
1442 .ok()
1443 .and_then(|v| {
1444 v.as_object().map(|obj| {
1445 obj.iter()
1446 .map(|(k, v)| {
1447 (k.clone(), v.as_str().unwrap_or("").to_string())
1448 })
1449 .collect()
1450 })
1451 })
1452 .unwrap_or_default();
1453 Some(CheckBody::FormUrlencoded(fields))
1454 }
1455 _ => {
1456 match serde_json::from_str::<serde_json::Value>(sample) {
1458 Ok(v) => Some(CheckBody::Json(v)),
1459 Err(_) => Some(CheckBody::Raw {
1460 content: sample.clone(),
1461 content_type: content_type.to_string(),
1462 }),
1463 }
1464 }
1465 }
1466 } else {
1467 None
1468 }
1469 }
1470 _ => None,
1471 };
1472
1473 let validation = self.determine_validation(feature, op);
1475
1476 ConformanceCheck {
1477 name: check_name.to_string(),
1478 method,
1479 path: url_path,
1480 headers: effective_headers,
1481 body,
1482 validation,
1483 }
1484 }
1485
1486 fn determine_validation(
1488 &self,
1489 feature: &ConformanceFeature,
1490 op: &AnnotatedOperation,
1491 ) -> CheckValidation {
1492 match feature {
1493 ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1494 ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1495 ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1496 ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1497 ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1498 ConformanceFeature::SecurityBearer
1499 | ConformanceFeature::SecurityBasic
1500 | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1501 min: 200,
1502 max_exclusive: 400,
1503 },
1504 ConformanceFeature::ResponseValidation => {
1505 if let Some(schema) = &op.response_schema {
1506 let schema_json = openapi_schema_to_json_schema(schema);
1508 CheckValidation::SchemaValidation {
1509 status_min: 200,
1510 status_max: 500,
1511 schema: schema_json,
1512 }
1513 } else {
1514 CheckValidation::StatusRange {
1515 min: 200,
1516 max_exclusive: 500,
1517 }
1518 }
1519 }
1520 _ => CheckValidation::StatusRange {
1521 min: 200,
1522 max_exclusive: 500,
1523 },
1524 }
1525 }
1526
1527 fn add_ref_get(&mut self, name: &str, path: &str) {
1529 self.checks.push(ConformanceCheck {
1530 name: name.to_string(),
1531 method: Method::GET,
1532 path: path.to_string(),
1533 headers: self.custom_headers_only(),
1534 body: None,
1535 validation: CheckValidation::StatusRange {
1536 min: 200,
1537 max_exclusive: 500,
1538 },
1539 });
1540 }
1541
1542 fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1544 let mut headers = Vec::new();
1545 for (k, v) in spec_headers {
1546 if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1548 continue;
1549 }
1550 headers.push((k.clone(), v.clone()));
1551 }
1552 headers.extend(self.config.custom_headers.clone());
1554 headers
1555 }
1556
1557 fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1559 for (k, v) in &self.config.custom_headers {
1560 if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1561 headers.push((k.clone(), v.clone()));
1562 }
1563 }
1564 headers
1565 }
1566
1567 fn custom_headers_only(&self) -> Vec<(String, String)> {
1569 self.config.custom_headers.clone()
1570 }
1571
1572 fn inject_security_headers(
1576 &self,
1577 schemes: &[SecuritySchemeInfo],
1578 headers: &mut Vec<(String, String)>,
1579 ) {
1580 let has_cookie_auth =
1582 self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1583 let mut to_add: Vec<(String, String)> = Vec::new();
1584
1585 for scheme in schemes {
1586 match scheme {
1587 SecuritySchemeInfo::Bearer => {
1588 if !has_cookie_auth
1589 && !Self::header_present(
1590 "Authorization",
1591 headers,
1592 &self.config.custom_headers,
1593 )
1594 {
1595 to_add.push((
1596 "Authorization".to_string(),
1597 "Bearer mockforge-conformance-test-token".to_string(),
1598 ));
1599 }
1600 }
1601 SecuritySchemeInfo::Basic => {
1602 if !has_cookie_auth
1603 && !Self::header_present(
1604 "Authorization",
1605 headers,
1606 &self.config.custom_headers,
1607 )
1608 {
1609 let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1610 use base64::Engine;
1611 let encoded =
1612 base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1613 to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1614 }
1615 }
1616 SecuritySchemeInfo::ApiKey { location, name } => match location {
1617 ApiKeyLocation::Header => {
1618 if !Self::header_present(name, headers, &self.config.custom_headers) {
1619 let key = self
1620 .config
1621 .api_key
1622 .as_deref()
1623 .unwrap_or("mockforge-conformance-test-key");
1624 to_add.push((name.clone(), key.to_string()));
1625 }
1626 }
1627 ApiKeyLocation::Cookie => {
1628 if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1629 to_add.push((
1630 "Cookie".to_string(),
1631 format!("{}=mockforge-conformance-test-session", name),
1632 ));
1633 }
1634 }
1635 ApiKeyLocation::Query => {
1636 }
1638 },
1639 }
1640 }
1641
1642 headers.extend(to_add);
1643 }
1644
1645 fn header_present(
1647 name: &str,
1648 headers: &[(String, String)],
1649 custom_headers: &[(String, String)],
1650 ) -> bool {
1651 headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1652 || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1653 }
1654
1655 fn add_custom_check(&mut self, check: &CustomCheck) {
1657 let method = match check.method.to_uppercase().as_str() {
1658 "GET" => Method::GET,
1659 "POST" => Method::POST,
1660 "PUT" => Method::PUT,
1661 "PATCH" => Method::PATCH,
1662 "DELETE" => Method::DELETE,
1663 "HEAD" => Method::HEAD,
1664 "OPTIONS" => Method::OPTIONS,
1665 _ => Method::GET,
1666 };
1667
1668 let mut headers: Vec<(String, String)> =
1670 check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1671 for (k, v) in &self.config.custom_headers {
1673 if !check.headers.contains_key(k) {
1674 headers.push((k.clone(), v.clone()));
1675 }
1676 }
1677 if check.body.is_some()
1679 && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1680 {
1681 headers.push(("Content-Type".to_string(), "application/json".to_string()));
1682 }
1683
1684 let upload_specs: Vec<&super::custom::UploadFile> =
1694 check.upload.as_ref().into_iter().chain(check.uploads.iter()).collect();
1695 let body = if check.body.is_some() {
1696 if !upload_specs.is_empty() {
1697 eprintln!(
1698 "warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring uploads",
1699 check.name
1700 );
1701 }
1702 check.body.as_ref().and_then(|b| {
1703 serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json)
1704 })
1705 } else if !upload_specs.is_empty() {
1706 let mut parts = Vec::with_capacity(upload_specs.len());
1707 for spec in upload_specs {
1708 match std::fs::read(&spec.path) {
1709 Ok(bytes) => {
1710 let filename = spec.filename.clone().unwrap_or_else(|| {
1711 std::path::Path::new(&spec.path)
1712 .file_name()
1713 .and_then(|n| n.to_str())
1714 .unwrap_or("upload.bin")
1715 .to_string()
1716 });
1717 parts.push(MultipartPart {
1718 bytes,
1719 content_type: spec.content_type.clone(),
1720 field_name: spec.field_name.clone(),
1721 filename,
1722 });
1723 }
1724 Err(e) => {
1725 eprintln!(
1726 "warning: custom check '{}' could not read upload '{}': {}",
1727 check.name, spec.path, e
1728 );
1729 }
1730 }
1731 }
1732 if parts.is_empty() {
1733 None
1734 } else {
1735 Some(CheckBody::Multipart { parts })
1736 }
1737 } else {
1738 None
1739 };
1740
1741 let expected_headers: Vec<(String, String)> =
1743 check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1744
1745 let expected_body_fields: Vec<(String, String)> = check
1747 .expected_body_fields
1748 .iter()
1749 .map(|f| (f.name.clone(), f.field_type.clone()))
1750 .collect();
1751
1752 let needs_chain = !check.extract.is_empty() || !check.repeat.is_default();
1757 let next_index = self.checks.len();
1758 if needs_chain {
1759 self.chain_meta.insert(
1760 next_index,
1761 ChainMeta {
1762 extract: check.extract.clone(),
1763 repeat: check.repeat.clone(),
1764 },
1765 );
1766 }
1767
1768 self.checks.push(ConformanceCheck {
1770 name: check.name.clone(),
1771 method,
1772 path: check.path.clone(),
1773 headers,
1774 body,
1775 validation: CheckValidation::Custom {
1776 expected_status: check.expected_status,
1777 expected_headers,
1778 expected_body_fields,
1779 },
1780 });
1781 }
1782}
1783
1784fn apply_chain_context(check: &ConformanceCheck, ctx: &ChainContext) -> ConformanceCheck {
1791 let path = ctx.substitute(&check.path);
1792 let headers = check.headers.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect();
1793 let body = check.body.as_ref().map(|b| match b {
1794 CheckBody::Json(v) => CheckBody::Json(substitute_in_json(v, ctx)),
1795 CheckBody::FormUrlencoded(fields) => CheckBody::FormUrlencoded(
1796 fields.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect(),
1797 ),
1798 CheckBody::Raw {
1799 content,
1800 content_type,
1801 } => CheckBody::Raw {
1802 content: ctx.substitute(content),
1803 content_type: content_type.clone(),
1804 },
1805 CheckBody::Multipart { parts } => CheckBody::Multipart {
1809 parts: parts.clone(),
1810 },
1811 });
1812 ConformanceCheck {
1813 name: check.name.clone(),
1814 method: check.method.clone(),
1815 path,
1816 headers,
1817 body,
1818 validation: check.validation.clone(),
1819 }
1820}
1821
1822fn substitute_in_json(value: &serde_json::Value, ctx: &ChainContext) -> serde_json::Value {
1823 use serde_json::Value;
1824 match value {
1825 Value::String(s) => Value::String(ctx.substitute(s)),
1826 Value::Array(arr) => Value::Array(arr.iter().map(|v| substitute_in_json(v, ctx)).collect()),
1827 Value::Object(obj) => Value::Object(
1828 obj.iter().map(|(k, v)| (k.clone(), substitute_in_json(v, ctx))).collect(),
1829 ),
1830 other => other.clone(),
1831 }
1832}
1833
1834fn extract_into_context(
1840 rules: &super::custom::ExtractRules,
1841 response_headers: &HashMap<String, String>,
1842 response_body: &str,
1843 ctx: &mut ChainContext,
1844) {
1845 for cookie_name in &rules.cookies {
1852 if let Some(raw) = response_headers
1853 .iter()
1854 .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
1855 .map(|(_, v)| v)
1856 {
1857 for entry in raw.split(',') {
1860 let head = entry.split(';').next().unwrap_or(entry).trim();
1861 if let Some((name, value)) = head.split_once('=') {
1862 if name.trim().eq_ignore_ascii_case(cookie_name) {
1863 ctx.cookies.insert(cookie_name.clone(), value.trim().to_string());
1864 break;
1865 }
1866 }
1867 }
1868 }
1869 }
1870 for (var_name, header_name) in &rules.headers {
1872 if let Some((_, value)) =
1873 response_headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(header_name))
1874 {
1875 ctx.vars.insert(var_name.clone(), value.clone());
1876 }
1877 }
1878 if !rules.body_fields.is_empty() {
1881 if let Ok(json) = serde_json::from_str::<serde_json::Value>(response_body) {
1882 for (var_name, field_path) in &rules.body_fields {
1883 if let Some(value) = lookup_json_path(&json, field_path) {
1884 let stringified = match value {
1885 serde_json::Value::String(s) => s.clone(),
1886 other => other.to_string(),
1887 };
1888 ctx.vars.insert(var_name.clone(), stringified);
1889 }
1890 }
1891 }
1892 }
1893}
1894
1895fn lookup_json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
1896 let mut current = value;
1897 for segment in path.split('.') {
1898 current = match current {
1899 serde_json::Value::Object(obj) => obj.get(segment)?,
1900 _ => return None,
1901 };
1902 }
1903 Some(current)
1904}
1905
1906fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1909 use openapiv3::{SchemaKind, Type};
1910
1911 match &schema.schema_kind {
1912 SchemaKind::Type(Type::Object(obj)) => {
1913 let mut props = serde_json::Map::new();
1914 for (name, prop_ref) in &obj.properties {
1915 if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1916 props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1917 }
1918 }
1919 let mut schema_obj = serde_json::json!({
1920 "type": "object",
1921 "properties": props,
1922 });
1923 if !obj.required.is_empty() {
1924 schema_obj["required"] = serde_json::Value::Array(
1925 obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1926 );
1927 }
1928 schema_obj
1929 }
1930 SchemaKind::Type(Type::Array(arr)) => {
1931 let mut schema_obj = serde_json::json!({"type": "array"});
1932 if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1933 schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1934 }
1935 schema_obj
1936 }
1937 SchemaKind::Type(Type::String(s)) => {
1938 let mut obj = serde_json::json!({"type": "string"});
1939 if let Some(min) = s.min_length {
1940 obj["minLength"] = serde_json::json!(min);
1941 }
1942 if let Some(max) = s.max_length {
1943 obj["maxLength"] = serde_json::json!(max);
1944 }
1945 if let Some(pattern) = &s.pattern {
1946 obj["pattern"] = serde_json::json!(pattern);
1947 }
1948 if !s.enumeration.is_empty() {
1949 obj["enum"] = serde_json::Value::Array(
1950 s.enumeration
1951 .iter()
1952 .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1953 .collect(),
1954 );
1955 }
1956 obj
1957 }
1958 SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1959 SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1960 SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1961 _ => serde_json::json!({}),
1962 }
1963}
1964
1965#[cfg(test)]
1966mod tests {
1967 use super::*;
1968
1969 #[test]
1974 fn chain_context_substitutes_all_token_kinds() {
1975 let mut ctx = ChainContext::default();
1976 ctx.vars.insert("csrf".to_string(), "abc123".to_string());
1977 ctx.vars.insert("trace".to_string(), "xyz".to_string());
1978 ctx.cookies.insert("session".to_string(), "deadbeef".to_string());
1979 assert_eq!(ctx.substitute("plain"), "plain");
1980 assert_eq!(ctx.substitute("X-CSRF: ${var:csrf}"), "X-CSRF: abc123");
1981 assert_eq!(ctx.substitute("Cookie: session=${cookie:session}"), "Cookie: session=deadbeef");
1982 assert_eq!(ctx.substitute("X-Trace: ${header:trace}"), "X-Trace: xyz");
1984 assert_eq!(ctx.substitute("missing: ${var:nope}"), "missing: ${var:nope}");
1986 }
1987
1988 #[test]
1992 fn extract_into_context_captures_cookies_headers_and_body_fields() {
1993 let mut headers = HashMap::new();
1994 headers.insert("Set-Cookie".to_string(), "session=abc123; Path=/; HttpOnly".to_string());
1995 headers.insert("X-CSRF-Token".to_string(), "csrf-token-xyz".to_string());
1996 let body = r#"{"data":{"token":"body-token-456"},"id":42}"#;
1997 let mut rules = super::super::custom::ExtractRules::default();
1998 rules.cookies.push("session".to_string());
1999 rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
2000 rules.body_fields.insert("nested_token".to_string(), "data.token".to_string());
2001 rules.body_fields.insert("user_id".to_string(), "id".to_string());
2002 let mut ctx = ChainContext::default();
2003 extract_into_context(&rules, &headers, body, &mut ctx);
2004 assert_eq!(ctx.cookies.get("session").map(|s| s.as_str()), Some("abc123"));
2005 assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-token-xyz"));
2006 assert_eq!(ctx.vars.get("nested_token").map(|s| s.as_str()), Some("body-token-456"));
2007 assert_eq!(ctx.vars.get("user_id").map(|s| s.as_str()), Some("42"));
2009 }
2010
2011 #[test]
2015 fn extract_into_context_skips_missing_captures_gracefully() {
2016 let mut headers = HashMap::new();
2017 headers.insert("X-CSRF-Token".to_string(), "csrf-value".to_string());
2018 let body = r#"{"id":1}"#;
2019 let mut rules = super::super::custom::ExtractRules::default();
2020 rules.cookies.push("never-set".to_string());
2021 rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
2022 let mut ctx = ChainContext::default();
2023 extract_into_context(&rules, &headers, body, &mut ctx);
2024 assert!(ctx.cookies.is_empty(), "missing cookie should not insert anything");
2025 assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-value"));
2026 }
2027
2028 #[test]
2033 fn apply_chain_context_substitutes_path_headers_and_body() {
2034 let mut ctx = ChainContext::default();
2035 ctx.vars.insert("user".to_string(), "alice".to_string());
2036 ctx.cookies.insert("sid".to_string(), "deadbeef".to_string());
2037 let check = ConformanceCheck {
2038 name: "custom:t".into(),
2039 method: Method::POST,
2040 path: "/users/${var:user}".into(),
2041 headers: vec![("Cookie".into(), "sid=${cookie:sid}".into())],
2042 body: Some(CheckBody::Json(serde_json::json!({"by": "${var:user}", "ts": 1}))),
2043 validation: CheckValidation::ExactStatus(200),
2044 };
2045 let substituted = apply_chain_context(&check, &ctx);
2046 assert_eq!(substituted.path, "/users/alice");
2047 assert_eq!(substituted.headers[0].1, "sid=deadbeef");
2048 match substituted.body {
2049 Some(CheckBody::Json(v)) => {
2050 assert_eq!(v["by"], "alice");
2051 assert_eq!(v["ts"], 1);
2052 }
2053 _ => panic!("expected json body"),
2054 }
2055 }
2056
2057 #[test]
2058 fn test_reference_check_count() {
2059 let config = ConformanceConfig {
2060 target_url: "http://localhost:3000".to_string(),
2061 ..Default::default()
2062 };
2063 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
2064 assert_eq!(executor.check_count(), 47);
2067 }
2068
2069 #[test]
2070 fn with_custom_checks_from_config_appends() {
2071 let custom_yaml = r#"
2076custom_checks:
2077 - name: "custom:health"
2078 path: /health
2079 method: GET
2080 expected_status: 200
2081 - name: "custom:create"
2082 path: /widgets
2083 method: POST
2084 expected_status: 201
2085"#;
2086 let parsed: CustomConformanceConfig =
2087 serde_yaml::from_str(custom_yaml).expect("YAML parses");
2088 assert_eq!(parsed.custom_checks.len(), 2);
2089
2090 let base = ConformanceConfig {
2091 target_url: "http://localhost:3000".to_string(),
2092 ..Default::default()
2093 };
2094 let executor = NativeConformanceExecutor::new(base)
2095 .unwrap()
2096 .with_reference_checks()
2097 .with_custom_checks_from_config(parsed)
2098 .expect("custom checks load");
2099 assert_eq!(executor.check_count(), 49);
2101 }
2102
2103 #[test]
2104 fn with_custom_checks_from_config_respects_filter() {
2105 let custom_yaml = r#"
2108custom_checks:
2109 - name: "custom:health"
2110 path: /health
2111 method: GET
2112 expected_status: 200
2113 - name: "custom:create-widget"
2114 path: /widgets
2115 method: POST
2116 expected_status: 201
2117"#;
2118 let parsed: CustomConformanceConfig =
2119 serde_yaml::from_str(custom_yaml).expect("YAML parses");
2120
2121 let base = ConformanceConfig {
2122 target_url: "http://localhost:3000".to_string(),
2123 categories: Some(vec!["no_such_category".to_string()]),
2126 custom_filter: Some("health".to_string()),
2127 ..Default::default()
2128 };
2129 let executor = NativeConformanceExecutor::new(base)
2130 .unwrap()
2131 .with_reference_checks()
2132 .with_custom_checks_from_config(parsed)
2133 .expect("custom checks load");
2134 assert_eq!(executor.check_count(), 1);
2137 }
2138
2139 #[test]
2140 fn with_custom_checks_from_config_rejects_bad_filter_regex() {
2141 let parsed: CustomConformanceConfig =
2142 serde_yaml::from_str("custom_checks: []").expect("YAML parses");
2143 let base = ConformanceConfig {
2144 target_url: "http://localhost:3000".to_string(),
2145 custom_filter: Some("[unclosed".to_string()),
2146 ..Default::default()
2147 };
2148 let result = NativeConformanceExecutor::new(base)
2149 .unwrap()
2150 .with_reference_checks()
2151 .with_custom_checks_from_config(parsed);
2152 assert!(result.is_err(), "bad regex should bubble up as BenchError");
2153 }
2154
2155 #[test]
2156 fn test_reference_checks_with_category_filter() {
2157 let config = ConformanceConfig {
2158 target_url: "http://localhost:3000".to_string(),
2159 categories: Some(vec!["Parameters".to_string()]),
2160 ..Default::default()
2161 };
2162 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
2163 assert_eq!(executor.check_count(), 7);
2164 }
2165
2166 #[test]
2167 fn test_validate_status_range() {
2168 let config = ConformanceConfig {
2169 target_url: "http://localhost:3000".to_string(),
2170 ..Default::default()
2171 };
2172 let executor = NativeConformanceExecutor::new(config).unwrap();
2173 let headers = HashMap::new();
2174
2175 assert!(
2176 executor
2177 .validate_response(
2178 &CheckValidation::StatusRange {
2179 min: 200,
2180 max_exclusive: 500,
2181 },
2182 200,
2183 &headers,
2184 "",
2185 )
2186 .0
2187 );
2188 assert!(
2189 executor
2190 .validate_response(
2191 &CheckValidation::StatusRange {
2192 min: 200,
2193 max_exclusive: 500,
2194 },
2195 404,
2196 &headers,
2197 "",
2198 )
2199 .0
2200 );
2201 assert!(
2202 !executor
2203 .validate_response(
2204 &CheckValidation::StatusRange {
2205 min: 200,
2206 max_exclusive: 500,
2207 },
2208 500,
2209 &headers,
2210 "",
2211 )
2212 .0
2213 );
2214 }
2215
2216 #[test]
2217 fn test_validate_exact_status() {
2218 let config = ConformanceConfig {
2219 target_url: "http://localhost:3000".to_string(),
2220 ..Default::default()
2221 };
2222 let executor = NativeConformanceExecutor::new(config).unwrap();
2223 let headers = HashMap::new();
2224
2225 assert!(
2226 executor
2227 .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
2228 .0
2229 );
2230 assert!(
2231 !executor
2232 .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
2233 .0
2234 );
2235 }
2236
2237 #[test]
2238 fn test_validate_schema() {
2239 let config = ConformanceConfig {
2240 target_url: "http://localhost:3000".to_string(),
2241 ..Default::default()
2242 };
2243 let executor = NativeConformanceExecutor::new(config).unwrap();
2244 let headers = HashMap::new();
2245
2246 let schema = serde_json::json!({
2247 "type": "object",
2248 "properties": {
2249 "name": {"type": "string"},
2250 "age": {"type": "integer"}
2251 },
2252 "required": ["name"]
2253 });
2254
2255 let (passed, violations) = executor.validate_response(
2256 &CheckValidation::SchemaValidation {
2257 status_min: 200,
2258 status_max: 300,
2259 schema: schema.clone(),
2260 },
2261 200,
2262 &headers,
2263 r#"{"name": "test", "age": 25}"#,
2264 );
2265 assert!(passed);
2266 assert!(violations.is_empty());
2267
2268 let (passed, violations) = executor.validate_response(
2270 &CheckValidation::SchemaValidation {
2271 status_min: 200,
2272 status_max: 300,
2273 schema: schema.clone(),
2274 },
2275 200,
2276 &headers,
2277 r#"{"age": 25}"#,
2278 );
2279 assert!(!passed);
2280 assert!(!violations.is_empty());
2281 assert_eq!(violations[0].violation_type, "Required");
2282 }
2283
2284 #[test]
2285 fn test_validate_custom() {
2286 let config = ConformanceConfig {
2287 target_url: "http://localhost:3000".to_string(),
2288 ..Default::default()
2289 };
2290 let executor = NativeConformanceExecutor::new(config).unwrap();
2291 let mut headers = HashMap::new();
2292 headers.insert("content-type".to_string(), "application/json".to_string());
2293
2294 assert!(
2295 executor
2296 .validate_response(
2297 &CheckValidation::Custom {
2298 expected_status: 200,
2299 expected_headers: vec![(
2300 "content-type".to_string(),
2301 "application/json".to_string(),
2302 )],
2303 expected_body_fields: vec![("name".to_string(), "string".to_string())],
2304 },
2305 200,
2306 &headers,
2307 r#"{"name": "test"}"#,
2308 )
2309 .0
2310 );
2311
2312 assert!(
2314 !executor
2315 .validate_response(
2316 &CheckValidation::Custom {
2317 expected_status: 200,
2318 expected_headers: vec![],
2319 expected_body_fields: vec![],
2320 },
2321 404,
2322 &headers,
2323 "",
2324 )
2325 .0
2326 );
2327 }
2328
2329 #[test]
2330 fn test_aggregate_results() {
2331 let results = vec![
2332 CheckResult {
2333 name: "check1".to_string(),
2334 passed: true,
2335 failure_detail: None,
2336 captured: None,
2337 },
2338 CheckResult {
2339 name: "check2".to_string(),
2340 passed: false,
2341 captured: None,
2342 failure_detail: Some(FailureDetail {
2343 check: "check2".to_string(),
2344 request: FailureRequest {
2345 method: "GET".to_string(),
2346 url: "http://example.com".to_string(),
2347 headers: HashMap::new(),
2348 body: String::new(),
2349 },
2350 response: FailureResponse {
2351 status: 500,
2352 headers: HashMap::new(),
2353 body: "error".to_string(),
2354 },
2355 expected: "status >= 200 && status < 500".to_string(),
2356 schema_violations: Vec::new(),
2357 }),
2358 },
2359 ];
2360
2361 let report = NativeConformanceExecutor::aggregate(results);
2362 let raw = report.raw_check_results();
2363 assert_eq!(raw.get("check1"), Some(&(1, 0)));
2364 assert_eq!(raw.get("check2"), Some(&(0, 1)));
2365 }
2366
2367 #[test]
2368 fn test_custom_check_building() {
2369 let config = ConformanceConfig {
2370 target_url: "http://localhost:3000".to_string(),
2371 ..Default::default()
2372 };
2373 let mut executor = NativeConformanceExecutor::new(config).unwrap();
2374
2375 let custom = CustomCheck {
2376 name: "custom:test-get".to_string(),
2377 path: "/api/test".to_string(),
2378 method: "GET".to_string(),
2379 expected_status: 200,
2380 body: None,
2381 expected_headers: HashMap::new(),
2382 expected_body_fields: vec![],
2383 headers: HashMap::new(),
2384 upload: None,
2385 uploads: vec![],
2386 extract: crate::conformance::custom::ExtractRules::default(),
2387 repeat: crate::conformance::custom::Repeat::default(),
2388 };
2389
2390 executor.add_custom_check(&custom);
2391 assert_eq!(executor.check_count(), 1);
2392 assert_eq!(executor.checks[0].name, "custom:test-get");
2393 }
2394
2395 #[test]
2396 fn test_openapi_schema_to_json_schema_object() {
2397 use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
2398
2399 let schema = Schema {
2400 schema_data: SchemaData::default(),
2401 schema_kind: SchemaKind::Type(Type::Object(ObjectType {
2402 required: vec!["name".to_string()],
2403 ..Default::default()
2404 })),
2405 };
2406
2407 let json = openapi_schema_to_json_schema(&schema);
2408 assert_eq!(json["type"], "object");
2409 assert_eq!(json["required"][0], "name");
2410 }
2411}