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: std::collections::HashMap<String, String>,
104 cookies: std::collections::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: std::collections::HashMap<usize, ChainMeta>,
228 chain_iterations: u32,
233}
234
235impl NativeConformanceExecutor {
236 pub fn new(config: ConformanceConfig) -> Result<Self> {
238 let mut builder = Client::builder()
239 .timeout(Duration::from_secs(30))
240 .connect_timeout(Duration::from_secs(10));
241
242 if config.skip_tls_verify {
243 builder = builder.danger_accept_invalid_certs(true);
244 }
245
246 let client = builder
247 .build()
248 .map_err(|e| BenchError::Other(format!("Failed to build HTTP client: {}", e)))?;
249
250 Ok(Self {
251 config,
252 client,
253 checks: Vec::new(),
254 chain_meta: std::collections::HashMap::new(),
255 chain_iterations: 1,
256 })
257 }
258
259 #[must_use]
262 pub fn with_reference_checks(mut self) -> Self {
263 if self.config.should_include_category("Parameters") {
265 self.add_ref_get("param:path:string", "/conformance/params/hello");
266 self.add_ref_get("param:path:integer", "/conformance/params/42");
267 self.add_ref_get("param:query:string", "/conformance/params/query?name=test");
268 self.add_ref_get("param:query:integer", "/conformance/params/query?count=10");
269 self.add_ref_get("param:query:array", "/conformance/params/query?tags=a&tags=b");
270 self.checks.push(ConformanceCheck {
271 name: "param:header".to_string(),
272 method: Method::GET,
273 path: "/conformance/params/header".to_string(),
274 headers: self
275 .merge_headers(vec![("X-Custom-Param".to_string(), "test-value".to_string())]),
276 body: None,
277 validation: CheckValidation::StatusRange {
278 min: 200,
279 max_exclusive: 500,
280 },
281 });
282 self.checks.push(ConformanceCheck {
283 name: "param:cookie".to_string(),
284 method: Method::GET,
285 path: "/conformance/params/cookie".to_string(),
286 headers: self
287 .merge_headers(vec![("Cookie".to_string(), "session=abc123".to_string())]),
288 body: None,
289 validation: CheckValidation::StatusRange {
290 min: 200,
291 max_exclusive: 500,
292 },
293 });
294 }
295
296 if self.config.should_include_category("Request Bodies") {
298 self.checks.push(ConformanceCheck {
299 name: "body:json".to_string(),
300 method: Method::POST,
301 path: "/conformance/body/json".to_string(),
302 headers: self.merge_headers(vec![(
303 "Content-Type".to_string(),
304 "application/json".to_string(),
305 )]),
306 body: Some(CheckBody::Json(serde_json::json!({"name": "test", "value": 42}))),
307 validation: CheckValidation::StatusRange {
308 min: 200,
309 max_exclusive: 500,
310 },
311 });
312 self.checks.push(ConformanceCheck {
313 name: "body:form-urlencoded".to_string(),
314 method: Method::POST,
315 path: "/conformance/body/form".to_string(),
316 headers: self.custom_headers_only(),
317 body: Some(CheckBody::FormUrlencoded(vec![
318 ("field1".to_string(), "value1".to_string()),
319 ("field2".to_string(), "value2".to_string()),
320 ])),
321 validation: CheckValidation::StatusRange {
322 min: 200,
323 max_exclusive: 500,
324 },
325 });
326 self.checks.push(ConformanceCheck {
327 name: "body:multipart".to_string(),
328 method: Method::POST,
329 path: "/conformance/body/multipart".to_string(),
330 headers: self.custom_headers_only(),
331 body: Some(CheckBody::Raw {
332 content: "test content".to_string(),
333 content_type: "text/plain".to_string(),
334 }),
335 validation: CheckValidation::StatusRange {
336 min: 200,
337 max_exclusive: 500,
338 },
339 });
340 }
341
342 if self.config.should_include_category("Schema Types") {
344 let types = [
345 ("string", r#"{"value": "hello"}"#, "schema:string"),
346 ("integer", r#"{"value": 42}"#, "schema:integer"),
347 ("number", r#"{"value": 3.14}"#, "schema:number"),
348 ("boolean", r#"{"value": true}"#, "schema:boolean"),
349 ("array", r#"{"value": [1, 2, 3]}"#, "schema:array"),
350 ("object", r#"{"value": {"nested": "data"}}"#, "schema:object"),
351 ];
352 for (type_name, body_str, check_name) in types {
353 self.checks.push(ConformanceCheck {
354 name: check_name.to_string(),
355 method: Method::POST,
356 path: format!("/conformance/schema/{}", type_name),
357 headers: self.merge_headers(vec![(
358 "Content-Type".to_string(),
359 "application/json".to_string(),
360 )]),
361 body: Some(CheckBody::Json(
362 serde_json::from_str(body_str).expect("valid JSON"),
363 )),
364 validation: CheckValidation::StatusRange {
365 min: 200,
366 max_exclusive: 500,
367 },
368 });
369 }
370 }
371
372 if self.config.should_include_category("Composition") {
374 let compositions = [
375 ("oneOf", r#"{"type": "string", "value": "test"}"#, "composition:oneOf"),
376 ("anyOf", r#"{"value": "test"}"#, "composition:anyOf"),
377 ("allOf", r#"{"name": "test", "id": 1}"#, "composition:allOf"),
378 ];
379 for (kind, body_str, check_name) in compositions {
380 self.checks.push(ConformanceCheck {
381 name: check_name.to_string(),
382 method: Method::POST,
383 path: format!("/conformance/composition/{}", kind),
384 headers: self.merge_headers(vec![(
385 "Content-Type".to_string(),
386 "application/json".to_string(),
387 )]),
388 body: Some(CheckBody::Json(
389 serde_json::from_str(body_str).expect("valid JSON"),
390 )),
391 validation: CheckValidation::StatusRange {
392 min: 200,
393 max_exclusive: 500,
394 },
395 });
396 }
397 }
398
399 if self.config.should_include_category("String Formats") {
401 let formats = [
402 ("date", r#"{"value": "2024-01-15"}"#, "format:date"),
403 ("date-time", r#"{"value": "2024-01-15T10:30:00Z"}"#, "format:date-time"),
404 ("email", r#"{"value": "test@example.com"}"#, "format:email"),
405 ("uuid", r#"{"value": "550e8400-e29b-41d4-a716-446655440000"}"#, "format:uuid"),
406 ("uri", r#"{"value": "https://example.com/path"}"#, "format:uri"),
407 ("ipv4", r#"{"value": "192.168.1.1"}"#, "format:ipv4"),
408 ("ipv6", r#"{"value": "::1"}"#, "format:ipv6"),
409 ];
410 for (fmt, body_str, check_name) in formats {
411 self.checks.push(ConformanceCheck {
412 name: check_name.to_string(),
413 method: Method::POST,
414 path: format!("/conformance/formats/{}", fmt),
415 headers: self.merge_headers(vec![(
416 "Content-Type".to_string(),
417 "application/json".to_string(),
418 )]),
419 body: Some(CheckBody::Json(
420 serde_json::from_str(body_str).expect("valid JSON"),
421 )),
422 validation: CheckValidation::StatusRange {
423 min: 200,
424 max_exclusive: 500,
425 },
426 });
427 }
428 }
429
430 if self.config.should_include_category("Constraints") {
432 let constraints = [
433 ("required", r#"{"required_field": "present"}"#, "constraint:required"),
434 ("optional", r#"{}"#, "constraint:optional"),
435 ("minmax", r#"{"value": 50}"#, "constraint:minmax"),
436 ("pattern", r#"{"value": "ABC-123"}"#, "constraint:pattern"),
437 ("enum", r#"{"status": "active"}"#, "constraint:enum"),
438 ];
439 for (kind, body_str, check_name) in constraints {
440 self.checks.push(ConformanceCheck {
441 name: check_name.to_string(),
442 method: Method::POST,
443 path: format!("/conformance/constraints/{}", kind),
444 headers: self.merge_headers(vec![(
445 "Content-Type".to_string(),
446 "application/json".to_string(),
447 )]),
448 body: Some(CheckBody::Json(
449 serde_json::from_str(body_str).expect("valid JSON"),
450 )),
451 validation: CheckValidation::StatusRange {
452 min: 200,
453 max_exclusive: 500,
454 },
455 });
456 }
457 }
458
459 if self.config.should_include_category("Response Codes") {
461 for (code_str, check_name) in [
462 ("200", "response:200"),
463 ("201", "response:201"),
464 ("204", "response:204"),
465 ("400", "response:400"),
466 ("404", "response:404"),
467 ] {
468 let code: u16 = code_str.parse().unwrap();
469 self.checks.push(ConformanceCheck {
470 name: check_name.to_string(),
471 method: Method::GET,
472 path: format!("/conformance/responses/{}", code_str),
473 headers: self.custom_headers_only(),
474 body: None,
475 validation: CheckValidation::ExactStatus(code),
476 });
477 }
478 }
479
480 if self.config.should_include_category("HTTP Methods") {
482 self.add_ref_get("method:GET", "/conformance/methods");
483 for (method, check_name) in [
484 (Method::POST, "method:POST"),
485 (Method::PUT, "method:PUT"),
486 (Method::PATCH, "method:PATCH"),
487 ] {
488 self.checks.push(ConformanceCheck {
489 name: check_name.to_string(),
490 method,
491 path: "/conformance/methods".to_string(),
492 headers: self.merge_headers(vec![(
493 "Content-Type".to_string(),
494 "application/json".to_string(),
495 )]),
496 body: Some(CheckBody::Json(serde_json::json!({"action": "test"}))),
497 validation: CheckValidation::StatusRange {
498 min: 200,
499 max_exclusive: 500,
500 },
501 });
502 }
503 for (method, check_name) in [
504 (Method::DELETE, "method:DELETE"),
505 (Method::HEAD, "method:HEAD"),
506 (Method::OPTIONS, "method:OPTIONS"),
507 ] {
508 self.checks.push(ConformanceCheck {
509 name: check_name.to_string(),
510 method,
511 path: "/conformance/methods".to_string(),
512 headers: self.custom_headers_only(),
513 body: None,
514 validation: CheckValidation::StatusRange {
515 min: 200,
516 max_exclusive: 500,
517 },
518 });
519 }
520 }
521
522 if self.config.should_include_category("Content Types") {
524 self.checks.push(ConformanceCheck {
525 name: "content:negotiation".to_string(),
526 method: Method::GET,
527 path: "/conformance/content-types".to_string(),
528 headers: self
529 .merge_headers(vec![("Accept".to_string(), "application/json".to_string())]),
530 body: None,
531 validation: CheckValidation::StatusRange {
532 min: 200,
533 max_exclusive: 500,
534 },
535 });
536 }
537
538 if self.config.should_include_category("Security") {
540 self.checks.push(ConformanceCheck {
542 name: "security:bearer".to_string(),
543 method: Method::GET,
544 path: "/conformance/security/bearer".to_string(),
545 headers: self.merge_headers(vec![(
546 "Authorization".to_string(),
547 "Bearer test-token-123".to_string(),
548 )]),
549 body: None,
550 validation: CheckValidation::StatusRange {
551 min: 200,
552 max_exclusive: 500,
553 },
554 });
555
556 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
558 self.checks.push(ConformanceCheck {
559 name: "security:apikey".to_string(),
560 method: Method::GET,
561 path: "/conformance/security/apikey".to_string(),
562 headers: self.merge_headers(vec![("X-API-Key".to_string(), api_key.to_string())]),
563 body: None,
564 validation: CheckValidation::StatusRange {
565 min: 200,
566 max_exclusive: 500,
567 },
568 });
569
570 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
572 use base64::Engine;
573 let encoded = base64::engine::general_purpose::STANDARD.encode(basic_creds.as_bytes());
574 self.checks.push(ConformanceCheck {
575 name: "security:basic".to_string(),
576 method: Method::GET,
577 path: "/conformance/security/basic".to_string(),
578 headers: self.merge_headers(vec![(
579 "Authorization".to_string(),
580 format!("Basic {}", encoded),
581 )]),
582 body: None,
583 validation: CheckValidation::StatusRange {
584 min: 200,
585 max_exclusive: 500,
586 },
587 });
588 }
589
590 self
591 }
592
593 #[must_use]
595 pub fn with_spec_driven_checks(mut self, operations: &[AnnotatedOperation]) -> Self {
596 let mut feature_seen: HashSet<&'static str> = HashSet::new();
598
599 for op in operations {
600 for feature in &op.features {
601 let category = feature.category();
602 if !self.config.should_include_category(category) {
603 continue;
604 }
605
606 let check_name_base = feature.check_name();
607
608 if self.config.all_operations {
609 let check_name = format!("{}:{}", check_name_base, op.path);
611 let check = self.build_spec_check(&check_name, op, feature);
612 self.checks.push(check);
613 } else {
614 if feature_seen.insert(check_name_base) {
616 let check_name = format!("{}:{}", check_name_base, op.path);
617 let check = self.build_spec_check(&check_name, op, feature);
618 self.checks.push(check);
619 }
620 }
621 }
622 }
623
624 self
625 }
626
627 pub fn with_custom_checks(self) -> Result<Self> {
629 let path = match &self.config.custom_checks_file {
630 Some(p) => p.clone(),
631 None => return Ok(self),
632 };
633 let custom_config = CustomConformanceConfig::from_file(&path)?;
634 self.append_custom_checks(&custom_config)
635 }
636
637 pub fn with_custom_checks_from_config(
645 self,
646 custom_config: CustomConformanceConfig,
647 ) -> Result<Self> {
648 self.append_custom_checks(&custom_config)
649 }
650
651 fn append_custom_checks(mut self, custom_config: &CustomConformanceConfig) -> Result<Self> {
656 let filter_re = match &self.config.custom_filter {
657 Some(pattern) => Some(regex::Regex::new(pattern).map_err(|e| {
658 BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
659 })?),
660 None => None,
661 };
662
663 let mut included = 0usize;
664 let total = custom_config.custom_checks.len();
665 self.chain_iterations = custom_config.chain_iterations.max(1);
669 for check in &custom_config.custom_checks {
670 if let Some(ref re) = filter_re {
671 if !re.is_match(&check.name) && !re.is_match(&check.path) {
672 continue;
673 }
674 }
675 self.add_custom_check(check);
676 included += 1;
677 }
678
679 if filter_re.is_some() {
680 tracing::info!("Custom check filter: {}/{} checks matched pattern", included, total);
681 }
682
683 Ok(self)
684 }
685
686 pub fn check_count(&self) -> usize {
688 self.checks.len()
689 }
690
691 pub async fn execute(&self) -> Result<ConformanceReport> {
693 let chain_iters = self.chain_iterations.max(1);
694 let mut results = Vec::with_capacity(self.checks.len() * chain_iters as usize);
695 let delay = self.config.request_delay_ms;
696
697 for _iter in 0..chain_iters {
698 let mut ctx = ChainContext::default();
703 for (i, check) in self.checks.iter().enumerate() {
704 if delay > 0 && i > 0 {
705 tokio::time::sleep(Duration::from_millis(delay)).await;
706 }
707 if let Some(meta) = self.chain_meta.get(&i).cloned() {
708 results.extend(self.execute_chain_check(check, &meta, &mut ctx).await);
709 } else {
710 results.push(self.execute_check(check).await);
711 }
712 }
713 }
714
715 if self.config.export_requests {
717 if let Some(ref output_dir) = self.config.output_dir {
718 let request_log: Vec<_> = results
719 .iter()
720 .filter_map(|r| {
721 r.captured.as_ref().map(|c| {
722 serde_json::json!({
723 "check": r.name,
724 "passed": r.passed,
725 "request": {
726 "method": c.method,
727 "url": c.url,
728 "headers": c.request_headers,
729 "body": c.request_body,
730 },
731 "response": {
732 "status": c.response_status,
733 "headers": c.response_headers,
734 "body": c.response_body,
735 },
736 })
737 })
738 })
739 .collect();
740 let path = output_dir.join("conformance-requests.json");
741 if let Ok(json) = serde_json::to_string_pretty(&request_log) {
742 let _ = std::fs::write(&path, json);
743 tracing::info!(
744 "Exported {} request/response pairs to {}",
745 request_log.len(),
746 path.display()
747 );
748 }
749 }
750 }
751
752 Ok(Self::aggregate(results))
753 }
754
755 pub async fn execute_with_progress(
757 &self,
758 tx: mpsc::Sender<ConformanceProgress>,
759 ) -> Result<ConformanceReport> {
760 let chain_iters = self.chain_iterations.max(1);
761 let total = self.checks.len() * chain_iters as usize;
762 let delay = self.config.request_delay_ms;
763 let _ = tx
764 .send(ConformanceProgress::Started {
765 total_checks: total,
766 })
767 .await;
768
769 let mut results = Vec::with_capacity(total);
770
771 for _iter in 0..chain_iters {
772 let mut ctx = ChainContext::default();
773 for (i, check) in self.checks.iter().enumerate() {
774 if delay > 0 && i > 0 {
775 tokio::time::sleep(Duration::from_millis(delay)).await;
776 }
777 let new_results = if let Some(meta) = self.chain_meta.get(&i).cloned() {
778 self.execute_chain_check(check, &meta, &mut ctx).await
779 } else {
780 vec![self.execute_check(check).await]
781 };
782 for result in new_results {
783 let passed = result.passed;
784 let name = result.name.clone();
785 results.push(result);
786 let _ = tx
787 .send(ConformanceProgress::CheckCompleted {
788 name,
789 passed,
790 checks_done: results.len(),
791 })
792 .await;
793 }
794 }
795 }
796
797 let _ = tx.send(ConformanceProgress::Finished).await;
798 Ok(Self::aggregate(results))
799 }
800
801 async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
803 let base_url = self.config.effective_base_url();
804 let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
805
806 let mut request = self.client.request(check.method.clone(), &url);
807
808 for (name, value) in &check.headers {
810 request = request.header(name.as_str(), value.as_str());
811 }
812
813 match &check.body {
815 Some(CheckBody::Json(value)) => {
816 request = request.json(value);
817 }
818 Some(CheckBody::FormUrlencoded(fields)) => {
819 request = request.form(fields);
820 }
821 Some(CheckBody::Raw {
822 content,
823 content_type,
824 }) => {
825 if content_type == "text/plain" && check.path.contains("multipart") {
827 let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
828 .file_name("test.txt")
829 .mime_str(content_type)
830 .unwrap_or_else(|_| {
831 reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
832 });
833 let form = reqwest::multipart::Form::new().part("field", part);
834 request = request.multipart(form);
835 } else {
836 request =
837 request.header("Content-Type", content_type.as_str()).body(content.clone());
838 }
839 }
840 Some(CheckBody::Multipart { parts }) => {
847 let mut form = reqwest::multipart::Form::new();
848 for part_spec in parts {
849 let mut part = reqwest::multipart::Part::bytes(part_spec.bytes.clone())
850 .file_name(part_spec.filename.clone());
851 part = match part.mime_str(&part_spec.content_type) {
852 Ok(p) => p,
853 Err(_) => reqwest::multipart::Part::bytes(part_spec.bytes.clone())
854 .file_name(part_spec.filename.clone())
855 .mime_str("application/octet-stream")
856 .expect("application/octet-stream is a valid MIME type"),
857 };
858 form = form.part(part_spec.field_name.clone(), part);
859 }
860 request = request.multipart(form);
861 }
862 None => {}
863 }
864
865 let req_body_str = match &check.body {
866 Some(CheckBody::Json(v)) => v.to_string(),
867 Some(CheckBody::FormUrlencoded(f)) => {
868 f.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&")
869 }
870 Some(CheckBody::Raw { content, .. }) => content.clone(),
871 Some(CheckBody::Multipart { parts }) => {
878 let summary: Vec<String> = parts
879 .iter()
880 .map(|p| {
881 format!("{} ({}, {} bytes)", p.filename, p.content_type, p.bytes.len())
882 })
883 .collect();
884 format!("<{} file(s): {}>", parts.len(), summary.join(", "))
885 }
886 None => String::new(),
887 };
888
889 let response = match request.send().await {
890 Ok(resp) => resp,
891 Err(e) => {
892 return CheckResult {
893 name: check.name.clone(),
894 passed: false,
895 failure_detail: Some(FailureDetail {
896 check: check.name.clone(),
897 request: FailureRequest {
898 method: check.method.to_string(),
899 url: url.clone(),
900 headers: HashMap::new(),
901 body: String::new(),
902 },
903 response: FailureResponse {
904 status: 0,
905 headers: HashMap::new(),
906 body: format!("Request failed: {}", e),
907 },
908 expected: format!("{:?}", check.validation),
909 schema_violations: Vec::new(),
910 }),
911 captured: None,
912 };
913 }
914 };
915
916 let status = response.status().as_u16();
917 let resp_headers: HashMap<String, String> = response
918 .headers()
919 .iter()
920 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
921 .collect();
922 let resp_body = response.text().await.unwrap_or_default();
923
924 let (passed, schema_violations) =
925 self.validate_response(&check.validation, status, &resp_headers, &resp_body);
926
927 let need_capture = self.config.export_requests || !self.chain_meta.is_empty();
934 let captured = if need_capture {
935 Some(CapturedExchange {
936 method: check.method.to_string(),
937 url: url.clone(),
938 request_headers: check.headers.iter().cloned().collect(),
939 request_body: req_body_str,
940 response_status: status,
941 response_headers: resp_headers.clone(),
942 response_body: if resp_body.len() > 2000 {
943 format!("{}...(truncated)", &resp_body[..2000])
944 } else {
945 resp_body.clone()
946 },
947 })
948 } else {
949 None
950 };
951
952 let failure_detail = if !passed {
953 Some(FailureDetail {
954 check: check.name.clone(),
955 request: FailureRequest {
956 method: check.method.to_string(),
957 url,
958 headers: check.headers.iter().cloned().collect(),
959 body: match &check.body {
960 Some(CheckBody::Json(v)) => v.to_string(),
961 Some(CheckBody::FormUrlencoded(f)) => f
962 .iter()
963 .map(|(k, v)| format!("{}={}", k, v))
964 .collect::<Vec<_>>()
965 .join("&"),
966 Some(CheckBody::Raw { content, .. }) => content.clone(),
967 Some(CheckBody::Multipart { parts }) => {
971 format!("<{} multipart file(s)>", parts.len())
972 }
973 None => String::new(),
974 },
975 },
976 response: FailureResponse {
977 status,
978 headers: resp_headers,
979 body: if resp_body.len() > 500 {
980 format!("{}...", &resp_body[..500])
981 } else {
982 resp_body
983 },
984 },
985 expected: Self::describe_validation(&check.validation),
986 schema_violations,
987 })
988 } else {
989 None
990 };
991
992 CheckResult {
993 name: check.name.clone(),
994 passed,
995 failure_detail,
996 captured,
997 }
998 }
999
1000 async fn execute_chain_check(
1012 &self,
1013 check: &ConformanceCheck,
1014 meta: &ChainMeta,
1015 ctx: &mut ChainContext,
1016 ) -> Vec<CheckResult> {
1017 let substituted = apply_chain_context(check, ctx);
1018 let count = meta.repeat.count.max(1);
1019 let results = match meta.repeat.mode {
1020 super::custom::RepeatMode::Sequential => {
1021 let mut out = Vec::with_capacity(count as usize);
1022 for _ in 0..count {
1023 out.push(self.execute_check(&substituted).await);
1024 }
1025 out
1026 }
1027 super::custom::RepeatMode::Parallel => {
1028 let futs = (0..count).map(|_| self.execute_check(&substituted));
1029 futures::future::join_all(futs).await
1030 }
1031 };
1032
1033 if !meta.extract.is_empty() {
1039 if let Some(first) = results.first() {
1040 if let Some(captured) = &first.captured {
1041 extract_into_context(
1042 &meta.extract,
1043 &captured.response_headers,
1044 &captured.response_body,
1045 ctx,
1046 );
1047 }
1048 }
1049 }
1050 results
1051 }
1052
1053 fn validate_response(
1058 &self,
1059 validation: &CheckValidation,
1060 status: u16,
1061 headers: &HashMap<String, String>,
1062 body: &str,
1063 ) -> (bool, Vec<SchemaViolation>) {
1064 match validation {
1065 CheckValidation::StatusRange { min, max_exclusive } => {
1066 (status >= *min && status < *max_exclusive, Vec::new())
1067 }
1068 CheckValidation::ExactStatus(expected) => (status == *expected, Vec::new()),
1069 CheckValidation::SchemaValidation {
1070 status_min,
1071 status_max,
1072 schema,
1073 } => {
1074 if status < *status_min || status >= *status_max {
1075 return (false, Vec::new());
1076 }
1077 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1079 return (
1080 false,
1081 vec![SchemaViolation {
1082 field_path: "/".to_string(),
1083 violation_type: "parse_error".to_string(),
1084 expected: "valid JSON".to_string(),
1085 actual: "non-JSON response body".to_string(),
1086 }],
1087 );
1088 };
1089 match jsonschema::validator_for(schema) {
1090 Ok(validator) => {
1091 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
1092 if errors.is_empty() {
1093 (true, Vec::new())
1094 } else {
1095 let violations = errors
1096 .iter()
1097 .map(|err| {
1098 let field_path = err.instance_path.to_string();
1099 let field_path = if field_path.is_empty() {
1100 "/".to_string()
1101 } else {
1102 field_path
1103 };
1104 SchemaViolation {
1105 field_path,
1106 violation_type: format!("{:?}", err.kind)
1107 .split('(')
1108 .next()
1109 .unwrap_or("unknown")
1110 .split('{')
1111 .next()
1112 .unwrap_or("unknown")
1113 .split(' ')
1114 .next()
1115 .unwrap_or("unknown")
1116 .trim()
1117 .to_string(),
1118 expected: {
1119 let schema_str = format!("{}", err.schema_path);
1123 match &err.kind {
1124 jsonschema::error::ValidationErrorKind::Type { kind } => {
1125 format!("type: {:?}", kind)
1126 }
1127 jsonschema::error::ValidationErrorKind::Required { property } => {
1128 format!("required field: {}", property)
1129 }
1130 _ => {
1131 schema_str
1133 .rsplit('/')
1134 .next()
1135 .unwrap_or(&schema_str)
1136 .to_string()
1137 }
1138 }
1139 },
1140 actual: format!("{}", err),
1141 }
1142 })
1143 .collect();
1144 (false, violations)
1145 }
1146 }
1147 Err(_) => {
1148 (
1150 false,
1151 vec![SchemaViolation {
1152 field_path: "/".to_string(),
1153 violation_type: "schema_compile_error".to_string(),
1154 expected: "valid JSON schema".to_string(),
1155 actual: "schema failed to compile".to_string(),
1156 }],
1157 )
1158 }
1159 }
1160 }
1161 CheckValidation::Custom {
1162 expected_status,
1163 expected_headers,
1164 expected_body_fields,
1165 } => {
1166 if status != *expected_status {
1167 return (false, Vec::new());
1168 }
1169 for (header_name, pattern) in expected_headers {
1171 let header_val = headers
1172 .get(header_name)
1173 .or_else(|| headers.get(&header_name.to_lowercase()))
1174 .map(|s| s.as_str())
1175 .unwrap_or("");
1176 if let Ok(re) = regex::Regex::new(pattern) {
1177 if !re.is_match(header_val) {
1178 return (false, Vec::new());
1179 }
1180 }
1181 }
1182 if !expected_body_fields.is_empty() {
1184 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1185 return (false, Vec::new());
1186 };
1187 for (field_name, field_type) in expected_body_fields {
1188 let field = &body_value[field_name];
1189 let ok = match field_type.as_str() {
1190 "string" => field.is_string(),
1191 "integer" => field.is_i64() || field.is_u64(),
1192 "number" => field.is_number(),
1193 "boolean" => field.is_boolean(),
1194 "array" => field.is_array(),
1195 "object" => field.is_object(),
1196 _ => !field.is_null(),
1197 };
1198 if !ok {
1199 return (false, Vec::new());
1200 }
1201 }
1202 }
1203 (true, Vec::new())
1204 }
1205 }
1206 }
1207
1208 fn describe_validation(validation: &CheckValidation) -> String {
1210 match validation {
1211 CheckValidation::StatusRange { min, max_exclusive } => {
1212 format!("status >= {} && status < {}", min, max_exclusive)
1213 }
1214 CheckValidation::ExactStatus(code) => format!("status === {}", code),
1215 CheckValidation::SchemaValidation {
1216 status_min,
1217 status_max,
1218 ..
1219 } => {
1220 format!("status >= {} && status < {} + schema validation", status_min, status_max)
1221 }
1222 CheckValidation::Custom {
1223 expected_status, ..
1224 } => {
1225 format!("status === {}", expected_status)
1226 }
1227 }
1228 }
1229
1230 fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
1232 let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
1233 let mut failure_details = Vec::new();
1234
1235 for result in results {
1236 let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
1237 if result.passed {
1238 entry.0 += 1;
1239 } else {
1240 entry.1 += 1;
1241 }
1242 if let Some(detail) = result.failure_detail {
1243 failure_details.push(detail);
1244 }
1245 }
1246
1247 ConformanceReport::from_results(check_results, failure_details)
1248 }
1249
1250 fn build_spec_check(
1254 &self,
1255 check_name: &str,
1256 op: &AnnotatedOperation,
1257 feature: &ConformanceFeature,
1258 ) -> ConformanceCheck {
1259 let mut url_path = op.path.clone();
1261 for (name, value) in &op.path_params {
1262 url_path = url_path.replace(&format!("{{{}}}", name), value);
1263 }
1264 if !op.query_params.is_empty() {
1266 let qs: Vec<String> =
1267 op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1268 url_path = format!("{}?{}", url_path, qs.join("&"));
1269 }
1270
1271 let mut effective_headers = self.effective_headers(&op.header_params);
1273
1274 if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
1276 let code = match feature {
1277 ConformanceFeature::Response400 => "400",
1278 ConformanceFeature::Response404 => "404",
1279 _ => unreachable!(),
1280 };
1281 effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
1282 }
1283
1284 let needs_auth = matches!(
1286 feature,
1287 ConformanceFeature::SecurityBearer
1288 | ConformanceFeature::SecurityBasic
1289 | ConformanceFeature::SecurityApiKey
1290 ) || !op.security_schemes.is_empty();
1291
1292 if needs_auth {
1293 self.inject_security_headers(&op.security_schemes, &mut effective_headers);
1294 }
1295
1296 let method = match op.method.as_str() {
1298 "GET" => Method::GET,
1299 "POST" => Method::POST,
1300 "PUT" => Method::PUT,
1301 "PATCH" => Method::PATCH,
1302 "DELETE" => Method::DELETE,
1303 "HEAD" => Method::HEAD,
1304 "OPTIONS" => Method::OPTIONS,
1305 _ => Method::GET,
1306 };
1307
1308 let body = match method {
1310 Method::POST | Method::PUT | Method::PATCH => {
1311 if let Some(sample) = &op.sample_body {
1312 let content_type =
1314 op.request_body_content_type.as_deref().unwrap_or("application/json");
1315 if !effective_headers
1316 .iter()
1317 .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1318 {
1319 effective_headers
1320 .push(("Content-Type".to_string(), content_type.to_string()));
1321 }
1322 match content_type {
1323 "application/x-www-form-urlencoded" => {
1324 let fields: Vec<(String, String)> = serde_json::from_str::<
1326 serde_json::Value,
1327 >(
1328 sample
1329 )
1330 .ok()
1331 .and_then(|v| {
1332 v.as_object().map(|obj| {
1333 obj.iter()
1334 .map(|(k, v)| {
1335 (k.clone(), v.as_str().unwrap_or("").to_string())
1336 })
1337 .collect()
1338 })
1339 })
1340 .unwrap_or_default();
1341 Some(CheckBody::FormUrlencoded(fields))
1342 }
1343 _ => {
1344 match serde_json::from_str::<serde_json::Value>(sample) {
1346 Ok(v) => Some(CheckBody::Json(v)),
1347 Err(_) => Some(CheckBody::Raw {
1348 content: sample.clone(),
1349 content_type: content_type.to_string(),
1350 }),
1351 }
1352 }
1353 }
1354 } else {
1355 None
1356 }
1357 }
1358 _ => None,
1359 };
1360
1361 let validation = self.determine_validation(feature, op);
1363
1364 ConformanceCheck {
1365 name: check_name.to_string(),
1366 method,
1367 path: url_path,
1368 headers: effective_headers,
1369 body,
1370 validation,
1371 }
1372 }
1373
1374 fn determine_validation(
1376 &self,
1377 feature: &ConformanceFeature,
1378 op: &AnnotatedOperation,
1379 ) -> CheckValidation {
1380 match feature {
1381 ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1382 ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1383 ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1384 ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1385 ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1386 ConformanceFeature::SecurityBearer
1387 | ConformanceFeature::SecurityBasic
1388 | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1389 min: 200,
1390 max_exclusive: 400,
1391 },
1392 ConformanceFeature::ResponseValidation => {
1393 if let Some(schema) = &op.response_schema {
1394 let schema_json = openapi_schema_to_json_schema(schema);
1396 CheckValidation::SchemaValidation {
1397 status_min: 200,
1398 status_max: 500,
1399 schema: schema_json,
1400 }
1401 } else {
1402 CheckValidation::StatusRange {
1403 min: 200,
1404 max_exclusive: 500,
1405 }
1406 }
1407 }
1408 _ => CheckValidation::StatusRange {
1409 min: 200,
1410 max_exclusive: 500,
1411 },
1412 }
1413 }
1414
1415 fn add_ref_get(&mut self, name: &str, path: &str) {
1417 self.checks.push(ConformanceCheck {
1418 name: name.to_string(),
1419 method: Method::GET,
1420 path: path.to_string(),
1421 headers: self.custom_headers_only(),
1422 body: None,
1423 validation: CheckValidation::StatusRange {
1424 min: 200,
1425 max_exclusive: 500,
1426 },
1427 });
1428 }
1429
1430 fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1432 let mut headers = Vec::new();
1433 for (k, v) in spec_headers {
1434 if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1436 continue;
1437 }
1438 headers.push((k.clone(), v.clone()));
1439 }
1440 headers.extend(self.config.custom_headers.clone());
1442 headers
1443 }
1444
1445 fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1447 for (k, v) in &self.config.custom_headers {
1448 if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1449 headers.push((k.clone(), v.clone()));
1450 }
1451 }
1452 headers
1453 }
1454
1455 fn custom_headers_only(&self) -> Vec<(String, String)> {
1457 self.config.custom_headers.clone()
1458 }
1459
1460 fn inject_security_headers(
1464 &self,
1465 schemes: &[SecuritySchemeInfo],
1466 headers: &mut Vec<(String, String)>,
1467 ) {
1468 let has_cookie_auth =
1470 self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1471 let mut to_add: Vec<(String, String)> = Vec::new();
1472
1473 for scheme in schemes {
1474 match scheme {
1475 SecuritySchemeInfo::Bearer => {
1476 if !has_cookie_auth
1477 && !Self::header_present(
1478 "Authorization",
1479 headers,
1480 &self.config.custom_headers,
1481 )
1482 {
1483 to_add.push((
1484 "Authorization".to_string(),
1485 "Bearer mockforge-conformance-test-token".to_string(),
1486 ));
1487 }
1488 }
1489 SecuritySchemeInfo::Basic => {
1490 if !has_cookie_auth
1491 && !Self::header_present(
1492 "Authorization",
1493 headers,
1494 &self.config.custom_headers,
1495 )
1496 {
1497 let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1498 use base64::Engine;
1499 let encoded =
1500 base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1501 to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1502 }
1503 }
1504 SecuritySchemeInfo::ApiKey { location, name } => match location {
1505 ApiKeyLocation::Header => {
1506 if !Self::header_present(name, headers, &self.config.custom_headers) {
1507 let key = self
1508 .config
1509 .api_key
1510 .as_deref()
1511 .unwrap_or("mockforge-conformance-test-key");
1512 to_add.push((name.clone(), key.to_string()));
1513 }
1514 }
1515 ApiKeyLocation::Cookie => {
1516 if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1517 to_add.push((
1518 "Cookie".to_string(),
1519 format!("{}=mockforge-conformance-test-session", name),
1520 ));
1521 }
1522 }
1523 ApiKeyLocation::Query => {
1524 }
1526 },
1527 }
1528 }
1529
1530 headers.extend(to_add);
1531 }
1532
1533 fn header_present(
1535 name: &str,
1536 headers: &[(String, String)],
1537 custom_headers: &[(String, String)],
1538 ) -> bool {
1539 headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1540 || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1541 }
1542
1543 fn add_custom_check(&mut self, check: &CustomCheck) {
1545 let method = match check.method.to_uppercase().as_str() {
1546 "GET" => Method::GET,
1547 "POST" => Method::POST,
1548 "PUT" => Method::PUT,
1549 "PATCH" => Method::PATCH,
1550 "DELETE" => Method::DELETE,
1551 "HEAD" => Method::HEAD,
1552 "OPTIONS" => Method::OPTIONS,
1553 _ => Method::GET,
1554 };
1555
1556 let mut headers: Vec<(String, String)> =
1558 check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1559 for (k, v) in &self.config.custom_headers {
1561 if !check.headers.contains_key(k) {
1562 headers.push((k.clone(), v.clone()));
1563 }
1564 }
1565 if check.body.is_some()
1567 && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1568 {
1569 headers.push(("Content-Type".to_string(), "application/json".to_string()));
1570 }
1571
1572 let upload_specs: Vec<&super::custom::UploadFile> =
1582 check.upload.as_ref().into_iter().chain(check.uploads.iter()).collect();
1583 let body = if check.body.is_some() {
1584 if !upload_specs.is_empty() {
1585 eprintln!(
1586 "warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring uploads",
1587 check.name
1588 );
1589 }
1590 check.body.as_ref().and_then(|b| {
1591 serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json)
1592 })
1593 } else if !upload_specs.is_empty() {
1594 let mut parts = Vec::with_capacity(upload_specs.len());
1595 for spec in upload_specs {
1596 match std::fs::read(&spec.path) {
1597 Ok(bytes) => {
1598 let filename = spec.filename.clone().unwrap_or_else(|| {
1599 std::path::Path::new(&spec.path)
1600 .file_name()
1601 .and_then(|n| n.to_str())
1602 .unwrap_or("upload.bin")
1603 .to_string()
1604 });
1605 parts.push(MultipartPart {
1606 bytes,
1607 content_type: spec.content_type.clone(),
1608 field_name: spec.field_name.clone(),
1609 filename,
1610 });
1611 }
1612 Err(e) => {
1613 eprintln!(
1614 "warning: custom check '{}' could not read upload '{}': {}",
1615 check.name, spec.path, e
1616 );
1617 }
1618 }
1619 }
1620 if parts.is_empty() {
1621 None
1622 } else {
1623 Some(CheckBody::Multipart { parts })
1624 }
1625 } else {
1626 None
1627 };
1628
1629 let expected_headers: Vec<(String, String)> =
1631 check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1632
1633 let expected_body_fields: Vec<(String, String)> = check
1635 .expected_body_fields
1636 .iter()
1637 .map(|f| (f.name.clone(), f.field_type.clone()))
1638 .collect();
1639
1640 let needs_chain = !check.extract.is_empty() || !check.repeat.is_default();
1645 let next_index = self.checks.len();
1646 if needs_chain {
1647 self.chain_meta.insert(
1648 next_index,
1649 ChainMeta {
1650 extract: check.extract.clone(),
1651 repeat: check.repeat.clone(),
1652 },
1653 );
1654 }
1655
1656 self.checks.push(ConformanceCheck {
1658 name: check.name.clone(),
1659 method,
1660 path: check.path.clone(),
1661 headers,
1662 body,
1663 validation: CheckValidation::Custom {
1664 expected_status: check.expected_status,
1665 expected_headers,
1666 expected_body_fields,
1667 },
1668 });
1669 }
1670}
1671
1672fn apply_chain_context(check: &ConformanceCheck, ctx: &ChainContext) -> ConformanceCheck {
1679 let path = ctx.substitute(&check.path);
1680 let headers = check.headers.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect();
1681 let body = check.body.as_ref().map(|b| match b {
1682 CheckBody::Json(v) => CheckBody::Json(substitute_in_json(v, ctx)),
1683 CheckBody::FormUrlencoded(fields) => CheckBody::FormUrlencoded(
1684 fields.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect(),
1685 ),
1686 CheckBody::Raw {
1687 content,
1688 content_type,
1689 } => CheckBody::Raw {
1690 content: ctx.substitute(content),
1691 content_type: content_type.clone(),
1692 },
1693 CheckBody::Multipart { parts } => CheckBody::Multipart {
1697 parts: parts.clone(),
1698 },
1699 });
1700 ConformanceCheck {
1701 name: check.name.clone(),
1702 method: check.method.clone(),
1703 path,
1704 headers,
1705 body,
1706 validation: check.validation.clone(),
1707 }
1708}
1709
1710fn substitute_in_json(value: &serde_json::Value, ctx: &ChainContext) -> serde_json::Value {
1711 use serde_json::Value;
1712 match value {
1713 Value::String(s) => Value::String(ctx.substitute(s)),
1714 Value::Array(arr) => Value::Array(arr.iter().map(|v| substitute_in_json(v, ctx)).collect()),
1715 Value::Object(obj) => Value::Object(
1716 obj.iter().map(|(k, v)| (k.clone(), substitute_in_json(v, ctx))).collect(),
1717 ),
1718 other => other.clone(),
1719 }
1720}
1721
1722fn extract_into_context(
1728 rules: &super::custom::ExtractRules,
1729 response_headers: &HashMap<String, String>,
1730 response_body: &str,
1731 ctx: &mut ChainContext,
1732) {
1733 for cookie_name in &rules.cookies {
1740 if let Some(raw) = response_headers
1741 .iter()
1742 .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
1743 .map(|(_, v)| v)
1744 {
1745 for entry in raw.split(',') {
1748 let head = entry.split(';').next().unwrap_or(entry).trim();
1749 if let Some((name, value)) = head.split_once('=') {
1750 if name.trim().eq_ignore_ascii_case(cookie_name) {
1751 ctx.cookies.insert(cookie_name.clone(), value.trim().to_string());
1752 break;
1753 }
1754 }
1755 }
1756 }
1757 }
1758 for (var_name, header_name) in &rules.headers {
1760 if let Some((_, value)) =
1761 response_headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(header_name))
1762 {
1763 ctx.vars.insert(var_name.clone(), value.clone());
1764 }
1765 }
1766 if !rules.body_fields.is_empty() {
1769 if let Ok(json) = serde_json::from_str::<serde_json::Value>(response_body) {
1770 for (var_name, field_path) in &rules.body_fields {
1771 if let Some(value) = lookup_json_path(&json, field_path) {
1772 let stringified = match value {
1773 serde_json::Value::String(s) => s.clone(),
1774 other => other.to_string(),
1775 };
1776 ctx.vars.insert(var_name.clone(), stringified);
1777 }
1778 }
1779 }
1780 }
1781}
1782
1783fn lookup_json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
1784 let mut current = value;
1785 for segment in path.split('.') {
1786 current = match current {
1787 serde_json::Value::Object(obj) => obj.get(segment)?,
1788 _ => return None,
1789 };
1790 }
1791 Some(current)
1792}
1793
1794fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1797 use openapiv3::{SchemaKind, Type};
1798
1799 match &schema.schema_kind {
1800 SchemaKind::Type(Type::Object(obj)) => {
1801 let mut props = serde_json::Map::new();
1802 for (name, prop_ref) in &obj.properties {
1803 if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1804 props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1805 }
1806 }
1807 let mut schema_obj = serde_json::json!({
1808 "type": "object",
1809 "properties": props,
1810 });
1811 if !obj.required.is_empty() {
1812 schema_obj["required"] = serde_json::Value::Array(
1813 obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1814 );
1815 }
1816 schema_obj
1817 }
1818 SchemaKind::Type(Type::Array(arr)) => {
1819 let mut schema_obj = serde_json::json!({"type": "array"});
1820 if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1821 schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1822 }
1823 schema_obj
1824 }
1825 SchemaKind::Type(Type::String(s)) => {
1826 let mut obj = serde_json::json!({"type": "string"});
1827 if let Some(min) = s.min_length {
1828 obj["minLength"] = serde_json::json!(min);
1829 }
1830 if let Some(max) = s.max_length {
1831 obj["maxLength"] = serde_json::json!(max);
1832 }
1833 if let Some(pattern) = &s.pattern {
1834 obj["pattern"] = serde_json::json!(pattern);
1835 }
1836 if !s.enumeration.is_empty() {
1837 obj["enum"] = serde_json::Value::Array(
1838 s.enumeration
1839 .iter()
1840 .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1841 .collect(),
1842 );
1843 }
1844 obj
1845 }
1846 SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1847 SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1848 SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1849 _ => serde_json::json!({}),
1850 }
1851}
1852
1853#[cfg(test)]
1854mod tests {
1855 use super::*;
1856
1857 #[test]
1862 fn chain_context_substitutes_all_token_kinds() {
1863 let mut ctx = ChainContext::default();
1864 ctx.vars.insert("csrf".to_string(), "abc123".to_string());
1865 ctx.vars.insert("trace".to_string(), "xyz".to_string());
1866 ctx.cookies.insert("session".to_string(), "deadbeef".to_string());
1867 assert_eq!(ctx.substitute("plain"), "plain");
1868 assert_eq!(ctx.substitute("X-CSRF: ${var:csrf}"), "X-CSRF: abc123");
1869 assert_eq!(ctx.substitute("Cookie: session=${cookie:session}"), "Cookie: session=deadbeef");
1870 assert_eq!(ctx.substitute("X-Trace: ${header:trace}"), "X-Trace: xyz");
1872 assert_eq!(ctx.substitute("missing: ${var:nope}"), "missing: ${var:nope}");
1874 }
1875
1876 #[test]
1880 fn extract_into_context_captures_cookies_headers_and_body_fields() {
1881 let mut headers = HashMap::new();
1882 headers.insert("Set-Cookie".to_string(), "session=abc123; Path=/; HttpOnly".to_string());
1883 headers.insert("X-CSRF-Token".to_string(), "csrf-token-xyz".to_string());
1884 let body = r#"{"data":{"token":"body-token-456"},"id":42}"#;
1885 let mut rules = super::super::custom::ExtractRules::default();
1886 rules.cookies.push("session".to_string());
1887 rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
1888 rules.body_fields.insert("nested_token".to_string(), "data.token".to_string());
1889 rules.body_fields.insert("user_id".to_string(), "id".to_string());
1890 let mut ctx = ChainContext::default();
1891 extract_into_context(&rules, &headers, body, &mut ctx);
1892 assert_eq!(ctx.cookies.get("session").map(|s| s.as_str()), Some("abc123"));
1893 assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-token-xyz"));
1894 assert_eq!(ctx.vars.get("nested_token").map(|s| s.as_str()), Some("body-token-456"));
1895 assert_eq!(ctx.vars.get("user_id").map(|s| s.as_str()), Some("42"));
1897 }
1898
1899 #[test]
1903 fn extract_into_context_skips_missing_captures_gracefully() {
1904 let mut headers = HashMap::new();
1905 headers.insert("X-CSRF-Token".to_string(), "csrf-value".to_string());
1906 let body = r#"{"id":1}"#;
1907 let mut rules = super::super::custom::ExtractRules::default();
1908 rules.cookies.push("never-set".to_string());
1909 rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
1910 let mut ctx = ChainContext::default();
1911 extract_into_context(&rules, &headers, body, &mut ctx);
1912 assert!(ctx.cookies.is_empty(), "missing cookie should not insert anything");
1913 assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-value"));
1914 }
1915
1916 #[test]
1921 fn apply_chain_context_substitutes_path_headers_and_body() {
1922 let mut ctx = ChainContext::default();
1923 ctx.vars.insert("user".to_string(), "alice".to_string());
1924 ctx.cookies.insert("sid".to_string(), "deadbeef".to_string());
1925 let check = ConformanceCheck {
1926 name: "custom:t".into(),
1927 method: Method::POST,
1928 path: "/users/${var:user}".into(),
1929 headers: vec![("Cookie".into(), "sid=${cookie:sid}".into())],
1930 body: Some(CheckBody::Json(serde_json::json!({"by": "${var:user}", "ts": 1}))),
1931 validation: CheckValidation::ExactStatus(200),
1932 };
1933 let substituted = apply_chain_context(&check, &ctx);
1934 assert_eq!(substituted.path, "/users/alice");
1935 assert_eq!(substituted.headers[0].1, "sid=deadbeef");
1936 match substituted.body {
1937 Some(CheckBody::Json(v)) => {
1938 assert_eq!(v["by"], "alice");
1939 assert_eq!(v["ts"], 1);
1940 }
1941 _ => panic!("expected json body"),
1942 }
1943 }
1944
1945 #[test]
1946 fn test_reference_check_count() {
1947 let config = ConformanceConfig {
1948 target_url: "http://localhost:3000".to_string(),
1949 ..Default::default()
1950 };
1951 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1952 assert_eq!(executor.check_count(), 47);
1955 }
1956
1957 #[test]
1958 fn with_custom_checks_from_config_appends() {
1959 let custom_yaml = r#"
1964custom_checks:
1965 - name: "custom:health"
1966 path: /health
1967 method: GET
1968 expected_status: 200
1969 - name: "custom:create"
1970 path: /widgets
1971 method: POST
1972 expected_status: 201
1973"#;
1974 let parsed: CustomConformanceConfig =
1975 serde_yaml::from_str(custom_yaml).expect("YAML parses");
1976 assert_eq!(parsed.custom_checks.len(), 2);
1977
1978 let base = ConformanceConfig {
1979 target_url: "http://localhost:3000".to_string(),
1980 ..Default::default()
1981 };
1982 let executor = NativeConformanceExecutor::new(base)
1983 .unwrap()
1984 .with_reference_checks()
1985 .with_custom_checks_from_config(parsed)
1986 .expect("custom checks load");
1987 assert_eq!(executor.check_count(), 49);
1989 }
1990
1991 #[test]
1992 fn with_custom_checks_from_config_respects_filter() {
1993 let custom_yaml = r#"
1996custom_checks:
1997 - name: "custom:health"
1998 path: /health
1999 method: GET
2000 expected_status: 200
2001 - name: "custom:create-widget"
2002 path: /widgets
2003 method: POST
2004 expected_status: 201
2005"#;
2006 let parsed: CustomConformanceConfig =
2007 serde_yaml::from_str(custom_yaml).expect("YAML parses");
2008
2009 let base = ConformanceConfig {
2010 target_url: "http://localhost:3000".to_string(),
2011 categories: Some(vec!["no_such_category".to_string()]),
2014 custom_filter: Some("health".to_string()),
2015 ..Default::default()
2016 };
2017 let executor = NativeConformanceExecutor::new(base)
2018 .unwrap()
2019 .with_reference_checks()
2020 .with_custom_checks_from_config(parsed)
2021 .expect("custom checks load");
2022 assert_eq!(executor.check_count(), 1);
2025 }
2026
2027 #[test]
2028 fn with_custom_checks_from_config_rejects_bad_filter_regex() {
2029 let parsed: CustomConformanceConfig =
2030 serde_yaml::from_str("custom_checks: []").expect("YAML parses");
2031 let base = ConformanceConfig {
2032 target_url: "http://localhost:3000".to_string(),
2033 custom_filter: Some("[unclosed".to_string()),
2034 ..Default::default()
2035 };
2036 let result = NativeConformanceExecutor::new(base)
2037 .unwrap()
2038 .with_reference_checks()
2039 .with_custom_checks_from_config(parsed);
2040 assert!(result.is_err(), "bad regex should bubble up as BenchError");
2041 }
2042
2043 #[test]
2044 fn test_reference_checks_with_category_filter() {
2045 let config = ConformanceConfig {
2046 target_url: "http://localhost:3000".to_string(),
2047 categories: Some(vec!["Parameters".to_string()]),
2048 ..Default::default()
2049 };
2050 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
2051 assert_eq!(executor.check_count(), 7);
2052 }
2053
2054 #[test]
2055 fn test_validate_status_range() {
2056 let config = ConformanceConfig {
2057 target_url: "http://localhost:3000".to_string(),
2058 ..Default::default()
2059 };
2060 let executor = NativeConformanceExecutor::new(config).unwrap();
2061 let headers = HashMap::new();
2062
2063 assert!(
2064 executor
2065 .validate_response(
2066 &CheckValidation::StatusRange {
2067 min: 200,
2068 max_exclusive: 500,
2069 },
2070 200,
2071 &headers,
2072 "",
2073 )
2074 .0
2075 );
2076 assert!(
2077 executor
2078 .validate_response(
2079 &CheckValidation::StatusRange {
2080 min: 200,
2081 max_exclusive: 500,
2082 },
2083 404,
2084 &headers,
2085 "",
2086 )
2087 .0
2088 );
2089 assert!(
2090 !executor
2091 .validate_response(
2092 &CheckValidation::StatusRange {
2093 min: 200,
2094 max_exclusive: 500,
2095 },
2096 500,
2097 &headers,
2098 "",
2099 )
2100 .0
2101 );
2102 }
2103
2104 #[test]
2105 fn test_validate_exact_status() {
2106 let config = ConformanceConfig {
2107 target_url: "http://localhost:3000".to_string(),
2108 ..Default::default()
2109 };
2110 let executor = NativeConformanceExecutor::new(config).unwrap();
2111 let headers = HashMap::new();
2112
2113 assert!(
2114 executor
2115 .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
2116 .0
2117 );
2118 assert!(
2119 !executor
2120 .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
2121 .0
2122 );
2123 }
2124
2125 #[test]
2126 fn test_validate_schema() {
2127 let config = ConformanceConfig {
2128 target_url: "http://localhost:3000".to_string(),
2129 ..Default::default()
2130 };
2131 let executor = NativeConformanceExecutor::new(config).unwrap();
2132 let headers = HashMap::new();
2133
2134 let schema = serde_json::json!({
2135 "type": "object",
2136 "properties": {
2137 "name": {"type": "string"},
2138 "age": {"type": "integer"}
2139 },
2140 "required": ["name"]
2141 });
2142
2143 let (passed, violations) = executor.validate_response(
2144 &CheckValidation::SchemaValidation {
2145 status_min: 200,
2146 status_max: 300,
2147 schema: schema.clone(),
2148 },
2149 200,
2150 &headers,
2151 r#"{"name": "test", "age": 25}"#,
2152 );
2153 assert!(passed);
2154 assert!(violations.is_empty());
2155
2156 let (passed, violations) = executor.validate_response(
2158 &CheckValidation::SchemaValidation {
2159 status_min: 200,
2160 status_max: 300,
2161 schema: schema.clone(),
2162 },
2163 200,
2164 &headers,
2165 r#"{"age": 25}"#,
2166 );
2167 assert!(!passed);
2168 assert!(!violations.is_empty());
2169 assert_eq!(violations[0].violation_type, "Required");
2170 }
2171
2172 #[test]
2173 fn test_validate_custom() {
2174 let config = ConformanceConfig {
2175 target_url: "http://localhost:3000".to_string(),
2176 ..Default::default()
2177 };
2178 let executor = NativeConformanceExecutor::new(config).unwrap();
2179 let mut headers = HashMap::new();
2180 headers.insert("content-type".to_string(), "application/json".to_string());
2181
2182 assert!(
2183 executor
2184 .validate_response(
2185 &CheckValidation::Custom {
2186 expected_status: 200,
2187 expected_headers: vec![(
2188 "content-type".to_string(),
2189 "application/json".to_string(),
2190 )],
2191 expected_body_fields: vec![("name".to_string(), "string".to_string())],
2192 },
2193 200,
2194 &headers,
2195 r#"{"name": "test"}"#,
2196 )
2197 .0
2198 );
2199
2200 assert!(
2202 !executor
2203 .validate_response(
2204 &CheckValidation::Custom {
2205 expected_status: 200,
2206 expected_headers: vec![],
2207 expected_body_fields: vec![],
2208 },
2209 404,
2210 &headers,
2211 "",
2212 )
2213 .0
2214 );
2215 }
2216
2217 #[test]
2218 fn test_aggregate_results() {
2219 let results = vec![
2220 CheckResult {
2221 name: "check1".to_string(),
2222 passed: true,
2223 failure_detail: None,
2224 captured: None,
2225 },
2226 CheckResult {
2227 name: "check2".to_string(),
2228 passed: false,
2229 captured: None,
2230 failure_detail: Some(FailureDetail {
2231 check: "check2".to_string(),
2232 request: FailureRequest {
2233 method: "GET".to_string(),
2234 url: "http://example.com".to_string(),
2235 headers: HashMap::new(),
2236 body: String::new(),
2237 },
2238 response: FailureResponse {
2239 status: 500,
2240 headers: HashMap::new(),
2241 body: "error".to_string(),
2242 },
2243 expected: "status >= 200 && status < 500".to_string(),
2244 schema_violations: Vec::new(),
2245 }),
2246 },
2247 ];
2248
2249 let report = NativeConformanceExecutor::aggregate(results);
2250 let raw = report.raw_check_results();
2251 assert_eq!(raw.get("check1"), Some(&(1, 0)));
2252 assert_eq!(raw.get("check2"), Some(&(0, 1)));
2253 }
2254
2255 #[test]
2256 fn test_custom_check_building() {
2257 let config = ConformanceConfig {
2258 target_url: "http://localhost:3000".to_string(),
2259 ..Default::default()
2260 };
2261 let mut executor = NativeConformanceExecutor::new(config).unwrap();
2262
2263 let custom = CustomCheck {
2264 name: "custom:test-get".to_string(),
2265 path: "/api/test".to_string(),
2266 method: "GET".to_string(),
2267 expected_status: 200,
2268 body: None,
2269 expected_headers: HashMap::new(),
2270 expected_body_fields: vec![],
2271 headers: HashMap::new(),
2272 upload: None,
2273 uploads: vec![],
2274 extract: crate::conformance::custom::ExtractRules::default(),
2275 repeat: crate::conformance::custom::Repeat::default(),
2276 };
2277
2278 executor.add_custom_check(&custom);
2279 assert_eq!(executor.check_count(), 1);
2280 assert_eq!(executor.checks[0].name, "custom:test-get");
2281 }
2282
2283 #[test]
2284 fn test_openapi_schema_to_json_schema_object() {
2285 use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
2286
2287 let schema = Schema {
2288 schema_data: SchemaData::default(),
2289 schema_kind: SchemaKind::Type(Type::Object(ObjectType {
2290 required: vec!["name".to_string()],
2291 ..Default::default()
2292 })),
2293 };
2294
2295 let json = openapi_schema_to_json_schema(&schema);
2296 assert_eq!(json["type"], "object");
2297 assert_eq!(json["required"][0], "name");
2298 }
2299}