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}
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: 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: if req_body_str.len() > 65536 {
947 format!(
948 "{}\n<truncated at 65536 bytes; full body was {} bytes>",
949 &req_body_str[..65536],
950 req_body_str.len()
951 )
952 } else {
953 req_body_str
954 },
955 response_status: status,
956 response_headers: resp_headers.clone(),
957 response_body: if resp_body.len() > 65536 {
958 format!(
959 "{}\n<truncated at 65536 bytes; full body was {} bytes>",
960 &resp_body[..65536],
961 resp_body.len()
962 )
963 } else {
964 resp_body.clone()
965 },
966 })
967 } else {
968 None
969 };
970
971 let failure_detail = if !passed {
972 Some(FailureDetail {
973 check: check.name.clone(),
974 request: FailureRequest {
975 method: check.method.to_string(),
976 url,
977 headers: check.headers.iter().cloned().collect(),
978 body: match &check.body {
979 Some(CheckBody::Json(v)) => v.to_string(),
980 Some(CheckBody::FormUrlencoded(f)) => f
981 .iter()
982 .map(|(k, v)| format!("{}={}", k, v))
983 .collect::<Vec<_>>()
984 .join("&"),
985 Some(CheckBody::Raw { content, .. }) => content.clone(),
986 Some(CheckBody::Multipart { parts }) => {
990 format!("<{} multipart file(s)>", parts.len())
991 }
992 None => String::new(),
993 },
994 },
995 response: FailureResponse {
996 status,
997 headers: resp_headers,
998 body: if resp_body.len() > 500 {
999 format!("{}...", &resp_body[..500])
1000 } else {
1001 resp_body
1002 },
1003 },
1004 expected: Self::describe_validation(&check.validation),
1005 schema_violations,
1006 })
1007 } else {
1008 None
1009 };
1010
1011 CheckResult {
1012 name: check.name.clone(),
1013 passed,
1014 failure_detail,
1015 captured,
1016 }
1017 }
1018
1019 async fn execute_chain_check(
1031 &self,
1032 check: &ConformanceCheck,
1033 meta: &ChainMeta,
1034 ctx: &mut ChainContext,
1035 ) -> Vec<CheckResult> {
1036 let substituted = apply_chain_context(check, ctx);
1037 let count = meta.repeat.count.max(1);
1038 let results = match meta.repeat.mode {
1039 super::custom::RepeatMode::Sequential => {
1040 let mut out = Vec::with_capacity(count as usize);
1041 for _ in 0..count {
1042 out.push(self.execute_check(&substituted).await);
1043 }
1044 out
1045 }
1046 super::custom::RepeatMode::Parallel => {
1047 let futs = (0..count).map(|_| self.execute_check(&substituted));
1048 futures::future::join_all(futs).await
1049 }
1050 };
1051
1052 if !meta.extract.is_empty() {
1058 if let Some(first) = results.first() {
1059 if let Some(captured) = &first.captured {
1060 extract_into_context(
1061 &meta.extract,
1062 &captured.response_headers,
1063 &captured.response_body,
1064 ctx,
1065 );
1066 }
1067 }
1068 }
1069 results
1070 }
1071
1072 fn validate_response(
1077 &self,
1078 validation: &CheckValidation,
1079 status: u16,
1080 headers: &HashMap<String, String>,
1081 body: &str,
1082 ) -> (bool, Vec<SchemaViolation>) {
1083 match validation {
1084 CheckValidation::StatusRange { min, max_exclusive } => {
1085 (status >= *min && status < *max_exclusive, Vec::new())
1086 }
1087 CheckValidation::ExactStatus(expected) => (status == *expected, Vec::new()),
1088 CheckValidation::SchemaValidation {
1089 status_min,
1090 status_max,
1091 schema,
1092 } => {
1093 if status < *status_min || status >= *status_max {
1094 return (false, Vec::new());
1095 }
1096 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1098 return (
1099 false,
1100 vec![SchemaViolation {
1101 field_path: "/".to_string(),
1102 violation_type: "parse_error".to_string(),
1103 expected: "valid JSON".to_string(),
1104 actual: "non-JSON response body".to_string(),
1105 }],
1106 );
1107 };
1108 match jsonschema::validator_for(schema) {
1109 Ok(validator) => {
1110 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
1111 if errors.is_empty() {
1112 (true, Vec::new())
1113 } else {
1114 let violations = errors
1115 .iter()
1116 .map(|err| {
1117 let field_path = err.instance_path.to_string();
1118 let field_path = if field_path.is_empty() {
1119 "/".to_string()
1120 } else {
1121 field_path
1122 };
1123 SchemaViolation {
1124 field_path,
1125 violation_type: format!("{:?}", err.kind)
1126 .split('(')
1127 .next()
1128 .unwrap_or("unknown")
1129 .split('{')
1130 .next()
1131 .unwrap_or("unknown")
1132 .split(' ')
1133 .next()
1134 .unwrap_or("unknown")
1135 .trim()
1136 .to_string(),
1137 expected: {
1138 let schema_str = format!("{}", err.schema_path);
1142 match &err.kind {
1143 jsonschema::error::ValidationErrorKind::Type { kind } => {
1144 format!("type: {:?}", kind)
1145 }
1146 jsonschema::error::ValidationErrorKind::Required { property } => {
1147 format!("required field: {}", property)
1148 }
1149 _ => {
1150 schema_str
1152 .rsplit('/')
1153 .next()
1154 .unwrap_or(&schema_str)
1155 .to_string()
1156 }
1157 }
1158 },
1159 actual: format!("{}", err),
1160 }
1161 })
1162 .collect();
1163 (false, violations)
1164 }
1165 }
1166 Err(_) => {
1167 (
1169 false,
1170 vec![SchemaViolation {
1171 field_path: "/".to_string(),
1172 violation_type: "schema_compile_error".to_string(),
1173 expected: "valid JSON schema".to_string(),
1174 actual: "schema failed to compile".to_string(),
1175 }],
1176 )
1177 }
1178 }
1179 }
1180 CheckValidation::Custom {
1181 expected_status,
1182 expected_headers,
1183 expected_body_fields,
1184 } => {
1185 if status != *expected_status {
1186 return (false, Vec::new());
1187 }
1188 for (header_name, pattern) in expected_headers {
1190 let header_val = headers
1191 .get(header_name)
1192 .or_else(|| headers.get(&header_name.to_lowercase()))
1193 .map(|s| s.as_str())
1194 .unwrap_or("");
1195 if let Ok(re) = regex::Regex::new(pattern) {
1196 if !re.is_match(header_val) {
1197 return (false, Vec::new());
1198 }
1199 }
1200 }
1201 if !expected_body_fields.is_empty() {
1203 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
1204 return (false, Vec::new());
1205 };
1206 for (field_name, field_type) in expected_body_fields {
1207 let field = &body_value[field_name];
1208 let ok = match field_type.as_str() {
1209 "string" => field.is_string(),
1210 "integer" => field.is_i64() || field.is_u64(),
1211 "number" => field.is_number(),
1212 "boolean" => field.is_boolean(),
1213 "array" => field.is_array(),
1214 "object" => field.is_object(),
1215 _ => !field.is_null(),
1216 };
1217 if !ok {
1218 return (false, Vec::new());
1219 }
1220 }
1221 }
1222 (true, Vec::new())
1223 }
1224 }
1225 }
1226
1227 fn describe_validation(validation: &CheckValidation) -> String {
1229 match validation {
1230 CheckValidation::StatusRange { min, max_exclusive } => {
1231 format!("status >= {} && status < {}", min, max_exclusive)
1232 }
1233 CheckValidation::ExactStatus(code) => format!("status === {}", code),
1234 CheckValidation::SchemaValidation {
1235 status_min,
1236 status_max,
1237 ..
1238 } => {
1239 format!("status >= {} && status < {} + schema validation", status_min, status_max)
1240 }
1241 CheckValidation::Custom {
1242 expected_status, ..
1243 } => {
1244 format!("status === {}", expected_status)
1245 }
1246 }
1247 }
1248
1249 fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
1251 let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
1252 let mut failure_details = Vec::new();
1253
1254 for result in results {
1255 let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
1256 if result.passed {
1257 entry.0 += 1;
1258 } else {
1259 entry.1 += 1;
1260 }
1261 if let Some(detail) = result.failure_detail {
1262 failure_details.push(detail);
1263 }
1264 }
1265
1266 ConformanceReport::from_results(check_results, failure_details)
1267 }
1268
1269 fn build_spec_check(
1273 &self,
1274 check_name: &str,
1275 op: &AnnotatedOperation,
1276 feature: &ConformanceFeature,
1277 ) -> ConformanceCheck {
1278 let mut url_path = op.path.clone();
1280 for (name, value) in &op.path_params {
1281 url_path = url_path.replace(&format!("{{{}}}", name), value);
1282 }
1283 if !op.query_params.is_empty() {
1285 let qs: Vec<String> =
1286 op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1287 url_path = format!("{}?{}", url_path, qs.join("&"));
1288 }
1289
1290 let mut effective_headers = self.effective_headers(&op.header_params);
1292
1293 if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
1295 let code = match feature {
1296 ConformanceFeature::Response400 => "400",
1297 ConformanceFeature::Response404 => "404",
1298 _ => unreachable!(),
1299 };
1300 effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
1301 }
1302
1303 let needs_auth = matches!(
1305 feature,
1306 ConformanceFeature::SecurityBearer
1307 | ConformanceFeature::SecurityBasic
1308 | ConformanceFeature::SecurityApiKey
1309 ) || !op.security_schemes.is_empty();
1310
1311 if needs_auth {
1312 self.inject_security_headers(&op.security_schemes, &mut effective_headers);
1313 }
1314
1315 let method = match op.method.as_str() {
1317 "GET" => Method::GET,
1318 "POST" => Method::POST,
1319 "PUT" => Method::PUT,
1320 "PATCH" => Method::PATCH,
1321 "DELETE" => Method::DELETE,
1322 "HEAD" => Method::HEAD,
1323 "OPTIONS" => Method::OPTIONS,
1324 _ => Method::GET,
1325 };
1326
1327 let body = match method {
1329 Method::POST | Method::PUT | Method::PATCH => {
1330 if let Some(sample) = &op.sample_body {
1331 let content_type =
1333 op.request_body_content_type.as_deref().unwrap_or("application/json");
1334 if !effective_headers
1335 .iter()
1336 .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1337 {
1338 effective_headers
1339 .push(("Content-Type".to_string(), content_type.to_string()));
1340 }
1341 match content_type {
1342 "application/x-www-form-urlencoded" => {
1343 let fields: Vec<(String, String)> = serde_json::from_str::<
1345 serde_json::Value,
1346 >(
1347 sample
1348 )
1349 .ok()
1350 .and_then(|v| {
1351 v.as_object().map(|obj| {
1352 obj.iter()
1353 .map(|(k, v)| {
1354 (k.clone(), v.as_str().unwrap_or("").to_string())
1355 })
1356 .collect()
1357 })
1358 })
1359 .unwrap_or_default();
1360 Some(CheckBody::FormUrlencoded(fields))
1361 }
1362 _ => {
1363 match serde_json::from_str::<serde_json::Value>(sample) {
1365 Ok(v) => Some(CheckBody::Json(v)),
1366 Err(_) => Some(CheckBody::Raw {
1367 content: sample.clone(),
1368 content_type: content_type.to_string(),
1369 }),
1370 }
1371 }
1372 }
1373 } else {
1374 None
1375 }
1376 }
1377 _ => None,
1378 };
1379
1380 let validation = self.determine_validation(feature, op);
1382
1383 ConformanceCheck {
1384 name: check_name.to_string(),
1385 method,
1386 path: url_path,
1387 headers: effective_headers,
1388 body,
1389 validation,
1390 }
1391 }
1392
1393 fn determine_validation(
1395 &self,
1396 feature: &ConformanceFeature,
1397 op: &AnnotatedOperation,
1398 ) -> CheckValidation {
1399 match feature {
1400 ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1401 ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1402 ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1403 ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1404 ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1405 ConformanceFeature::SecurityBearer
1406 | ConformanceFeature::SecurityBasic
1407 | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1408 min: 200,
1409 max_exclusive: 400,
1410 },
1411 ConformanceFeature::ResponseValidation => {
1412 if let Some(schema) = &op.response_schema {
1413 let schema_json = openapi_schema_to_json_schema(schema);
1415 CheckValidation::SchemaValidation {
1416 status_min: 200,
1417 status_max: 500,
1418 schema: schema_json,
1419 }
1420 } else {
1421 CheckValidation::StatusRange {
1422 min: 200,
1423 max_exclusive: 500,
1424 }
1425 }
1426 }
1427 _ => CheckValidation::StatusRange {
1428 min: 200,
1429 max_exclusive: 500,
1430 },
1431 }
1432 }
1433
1434 fn add_ref_get(&mut self, name: &str, path: &str) {
1436 self.checks.push(ConformanceCheck {
1437 name: name.to_string(),
1438 method: Method::GET,
1439 path: path.to_string(),
1440 headers: self.custom_headers_only(),
1441 body: None,
1442 validation: CheckValidation::StatusRange {
1443 min: 200,
1444 max_exclusive: 500,
1445 },
1446 });
1447 }
1448
1449 fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1451 let mut headers = Vec::new();
1452 for (k, v) in spec_headers {
1453 if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1455 continue;
1456 }
1457 headers.push((k.clone(), v.clone()));
1458 }
1459 headers.extend(self.config.custom_headers.clone());
1461 headers
1462 }
1463
1464 fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1466 for (k, v) in &self.config.custom_headers {
1467 if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1468 headers.push((k.clone(), v.clone()));
1469 }
1470 }
1471 headers
1472 }
1473
1474 fn custom_headers_only(&self) -> Vec<(String, String)> {
1476 self.config.custom_headers.clone()
1477 }
1478
1479 fn inject_security_headers(
1483 &self,
1484 schemes: &[SecuritySchemeInfo],
1485 headers: &mut Vec<(String, String)>,
1486 ) {
1487 let has_cookie_auth =
1489 self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1490 let mut to_add: Vec<(String, String)> = Vec::new();
1491
1492 for scheme in schemes {
1493 match scheme {
1494 SecuritySchemeInfo::Bearer => {
1495 if !has_cookie_auth
1496 && !Self::header_present(
1497 "Authorization",
1498 headers,
1499 &self.config.custom_headers,
1500 )
1501 {
1502 to_add.push((
1503 "Authorization".to_string(),
1504 "Bearer mockforge-conformance-test-token".to_string(),
1505 ));
1506 }
1507 }
1508 SecuritySchemeInfo::Basic => {
1509 if !has_cookie_auth
1510 && !Self::header_present(
1511 "Authorization",
1512 headers,
1513 &self.config.custom_headers,
1514 )
1515 {
1516 let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1517 use base64::Engine;
1518 let encoded =
1519 base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1520 to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1521 }
1522 }
1523 SecuritySchemeInfo::ApiKey { location, name } => match location {
1524 ApiKeyLocation::Header => {
1525 if !Self::header_present(name, headers, &self.config.custom_headers) {
1526 let key = self
1527 .config
1528 .api_key
1529 .as_deref()
1530 .unwrap_or("mockforge-conformance-test-key");
1531 to_add.push((name.clone(), key.to_string()));
1532 }
1533 }
1534 ApiKeyLocation::Cookie => {
1535 if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1536 to_add.push((
1537 "Cookie".to_string(),
1538 format!("{}=mockforge-conformance-test-session", name),
1539 ));
1540 }
1541 }
1542 ApiKeyLocation::Query => {
1543 }
1545 },
1546 }
1547 }
1548
1549 headers.extend(to_add);
1550 }
1551
1552 fn header_present(
1554 name: &str,
1555 headers: &[(String, String)],
1556 custom_headers: &[(String, String)],
1557 ) -> bool {
1558 headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1559 || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1560 }
1561
1562 fn add_custom_check(&mut self, check: &CustomCheck) {
1564 let method = match check.method.to_uppercase().as_str() {
1565 "GET" => Method::GET,
1566 "POST" => Method::POST,
1567 "PUT" => Method::PUT,
1568 "PATCH" => Method::PATCH,
1569 "DELETE" => Method::DELETE,
1570 "HEAD" => Method::HEAD,
1571 "OPTIONS" => Method::OPTIONS,
1572 _ => Method::GET,
1573 };
1574
1575 let mut headers: Vec<(String, String)> =
1577 check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1578 for (k, v) in &self.config.custom_headers {
1580 if !check.headers.contains_key(k) {
1581 headers.push((k.clone(), v.clone()));
1582 }
1583 }
1584 if check.body.is_some()
1586 && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1587 {
1588 headers.push(("Content-Type".to_string(), "application/json".to_string()));
1589 }
1590
1591 let upload_specs: Vec<&super::custom::UploadFile> =
1601 check.upload.as_ref().into_iter().chain(check.uploads.iter()).collect();
1602 let body = if check.body.is_some() {
1603 if !upload_specs.is_empty() {
1604 eprintln!(
1605 "warning: custom check '{}' has both `body` and `upload`/`uploads`; ignoring uploads",
1606 check.name
1607 );
1608 }
1609 check.body.as_ref().and_then(|b| {
1610 serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json)
1611 })
1612 } else if !upload_specs.is_empty() {
1613 let mut parts = Vec::with_capacity(upload_specs.len());
1614 for spec in upload_specs {
1615 match std::fs::read(&spec.path) {
1616 Ok(bytes) => {
1617 let filename = spec.filename.clone().unwrap_or_else(|| {
1618 std::path::Path::new(&spec.path)
1619 .file_name()
1620 .and_then(|n| n.to_str())
1621 .unwrap_or("upload.bin")
1622 .to_string()
1623 });
1624 parts.push(MultipartPart {
1625 bytes,
1626 content_type: spec.content_type.clone(),
1627 field_name: spec.field_name.clone(),
1628 filename,
1629 });
1630 }
1631 Err(e) => {
1632 eprintln!(
1633 "warning: custom check '{}' could not read upload '{}': {}",
1634 check.name, spec.path, e
1635 );
1636 }
1637 }
1638 }
1639 if parts.is_empty() {
1640 None
1641 } else {
1642 Some(CheckBody::Multipart { parts })
1643 }
1644 } else {
1645 None
1646 };
1647
1648 let expected_headers: Vec<(String, String)> =
1650 check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1651
1652 let expected_body_fields: Vec<(String, String)> = check
1654 .expected_body_fields
1655 .iter()
1656 .map(|f| (f.name.clone(), f.field_type.clone()))
1657 .collect();
1658
1659 let needs_chain = !check.extract.is_empty() || !check.repeat.is_default();
1664 let next_index = self.checks.len();
1665 if needs_chain {
1666 self.chain_meta.insert(
1667 next_index,
1668 ChainMeta {
1669 extract: check.extract.clone(),
1670 repeat: check.repeat.clone(),
1671 },
1672 );
1673 }
1674
1675 self.checks.push(ConformanceCheck {
1677 name: check.name.clone(),
1678 method,
1679 path: check.path.clone(),
1680 headers,
1681 body,
1682 validation: CheckValidation::Custom {
1683 expected_status: check.expected_status,
1684 expected_headers,
1685 expected_body_fields,
1686 },
1687 });
1688 }
1689}
1690
1691fn apply_chain_context(check: &ConformanceCheck, ctx: &ChainContext) -> ConformanceCheck {
1698 let path = ctx.substitute(&check.path);
1699 let headers = check.headers.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect();
1700 let body = check.body.as_ref().map(|b| match b {
1701 CheckBody::Json(v) => CheckBody::Json(substitute_in_json(v, ctx)),
1702 CheckBody::FormUrlencoded(fields) => CheckBody::FormUrlencoded(
1703 fields.iter().map(|(k, v)| (k.clone(), ctx.substitute(v))).collect(),
1704 ),
1705 CheckBody::Raw {
1706 content,
1707 content_type,
1708 } => CheckBody::Raw {
1709 content: ctx.substitute(content),
1710 content_type: content_type.clone(),
1711 },
1712 CheckBody::Multipart { parts } => CheckBody::Multipart {
1716 parts: parts.clone(),
1717 },
1718 });
1719 ConformanceCheck {
1720 name: check.name.clone(),
1721 method: check.method.clone(),
1722 path,
1723 headers,
1724 body,
1725 validation: check.validation.clone(),
1726 }
1727}
1728
1729fn substitute_in_json(value: &serde_json::Value, ctx: &ChainContext) -> serde_json::Value {
1730 use serde_json::Value;
1731 match value {
1732 Value::String(s) => Value::String(ctx.substitute(s)),
1733 Value::Array(arr) => Value::Array(arr.iter().map(|v| substitute_in_json(v, ctx)).collect()),
1734 Value::Object(obj) => Value::Object(
1735 obj.iter().map(|(k, v)| (k.clone(), substitute_in_json(v, ctx))).collect(),
1736 ),
1737 other => other.clone(),
1738 }
1739}
1740
1741fn extract_into_context(
1747 rules: &super::custom::ExtractRules,
1748 response_headers: &HashMap<String, String>,
1749 response_body: &str,
1750 ctx: &mut ChainContext,
1751) {
1752 for cookie_name in &rules.cookies {
1759 if let Some(raw) = response_headers
1760 .iter()
1761 .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
1762 .map(|(_, v)| v)
1763 {
1764 for entry in raw.split(',') {
1767 let head = entry.split(';').next().unwrap_or(entry).trim();
1768 if let Some((name, value)) = head.split_once('=') {
1769 if name.trim().eq_ignore_ascii_case(cookie_name) {
1770 ctx.cookies.insert(cookie_name.clone(), value.trim().to_string());
1771 break;
1772 }
1773 }
1774 }
1775 }
1776 }
1777 for (var_name, header_name) in &rules.headers {
1779 if let Some((_, value)) =
1780 response_headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(header_name))
1781 {
1782 ctx.vars.insert(var_name.clone(), value.clone());
1783 }
1784 }
1785 if !rules.body_fields.is_empty() {
1788 if let Ok(json) = serde_json::from_str::<serde_json::Value>(response_body) {
1789 for (var_name, field_path) in &rules.body_fields {
1790 if let Some(value) = lookup_json_path(&json, field_path) {
1791 let stringified = match value {
1792 serde_json::Value::String(s) => s.clone(),
1793 other => other.to_string(),
1794 };
1795 ctx.vars.insert(var_name.clone(), stringified);
1796 }
1797 }
1798 }
1799 }
1800}
1801
1802fn lookup_json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
1803 let mut current = value;
1804 for segment in path.split('.') {
1805 current = match current {
1806 serde_json::Value::Object(obj) => obj.get(segment)?,
1807 _ => return None,
1808 };
1809 }
1810 Some(current)
1811}
1812
1813fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1816 use openapiv3::{SchemaKind, Type};
1817
1818 match &schema.schema_kind {
1819 SchemaKind::Type(Type::Object(obj)) => {
1820 let mut props = serde_json::Map::new();
1821 for (name, prop_ref) in &obj.properties {
1822 if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1823 props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1824 }
1825 }
1826 let mut schema_obj = serde_json::json!({
1827 "type": "object",
1828 "properties": props,
1829 });
1830 if !obj.required.is_empty() {
1831 schema_obj["required"] = serde_json::Value::Array(
1832 obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1833 );
1834 }
1835 schema_obj
1836 }
1837 SchemaKind::Type(Type::Array(arr)) => {
1838 let mut schema_obj = serde_json::json!({"type": "array"});
1839 if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1840 schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1841 }
1842 schema_obj
1843 }
1844 SchemaKind::Type(Type::String(s)) => {
1845 let mut obj = serde_json::json!({"type": "string"});
1846 if let Some(min) = s.min_length {
1847 obj["minLength"] = serde_json::json!(min);
1848 }
1849 if let Some(max) = s.max_length {
1850 obj["maxLength"] = serde_json::json!(max);
1851 }
1852 if let Some(pattern) = &s.pattern {
1853 obj["pattern"] = serde_json::json!(pattern);
1854 }
1855 if !s.enumeration.is_empty() {
1856 obj["enum"] = serde_json::Value::Array(
1857 s.enumeration
1858 .iter()
1859 .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1860 .collect(),
1861 );
1862 }
1863 obj
1864 }
1865 SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1866 SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1867 SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1868 _ => serde_json::json!({}),
1869 }
1870}
1871
1872#[cfg(test)]
1873mod tests {
1874 use super::*;
1875
1876 #[test]
1881 fn chain_context_substitutes_all_token_kinds() {
1882 let mut ctx = ChainContext::default();
1883 ctx.vars.insert("csrf".to_string(), "abc123".to_string());
1884 ctx.vars.insert("trace".to_string(), "xyz".to_string());
1885 ctx.cookies.insert("session".to_string(), "deadbeef".to_string());
1886 assert_eq!(ctx.substitute("plain"), "plain");
1887 assert_eq!(ctx.substitute("X-CSRF: ${var:csrf}"), "X-CSRF: abc123");
1888 assert_eq!(ctx.substitute("Cookie: session=${cookie:session}"), "Cookie: session=deadbeef");
1889 assert_eq!(ctx.substitute("X-Trace: ${header:trace}"), "X-Trace: xyz");
1891 assert_eq!(ctx.substitute("missing: ${var:nope}"), "missing: ${var:nope}");
1893 }
1894
1895 #[test]
1899 fn extract_into_context_captures_cookies_headers_and_body_fields() {
1900 let mut headers = HashMap::new();
1901 headers.insert("Set-Cookie".to_string(), "session=abc123; Path=/; HttpOnly".to_string());
1902 headers.insert("X-CSRF-Token".to_string(), "csrf-token-xyz".to_string());
1903 let body = r#"{"data":{"token":"body-token-456"},"id":42}"#;
1904 let mut rules = super::super::custom::ExtractRules::default();
1905 rules.cookies.push("session".to_string());
1906 rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
1907 rules.body_fields.insert("nested_token".to_string(), "data.token".to_string());
1908 rules.body_fields.insert("user_id".to_string(), "id".to_string());
1909 let mut ctx = ChainContext::default();
1910 extract_into_context(&rules, &headers, body, &mut ctx);
1911 assert_eq!(ctx.cookies.get("session").map(|s| s.as_str()), Some("abc123"));
1912 assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-token-xyz"));
1913 assert_eq!(ctx.vars.get("nested_token").map(|s| s.as_str()), Some("body-token-456"));
1914 assert_eq!(ctx.vars.get("user_id").map(|s| s.as_str()), Some("42"));
1916 }
1917
1918 #[test]
1922 fn extract_into_context_skips_missing_captures_gracefully() {
1923 let mut headers = HashMap::new();
1924 headers.insert("X-CSRF-Token".to_string(), "csrf-value".to_string());
1925 let body = r#"{"id":1}"#;
1926 let mut rules = super::super::custom::ExtractRules::default();
1927 rules.cookies.push("never-set".to_string());
1928 rules.headers.insert("csrf".to_string(), "X-CSRF-Token".to_string());
1929 let mut ctx = ChainContext::default();
1930 extract_into_context(&rules, &headers, body, &mut ctx);
1931 assert!(ctx.cookies.is_empty(), "missing cookie should not insert anything");
1932 assert_eq!(ctx.vars.get("csrf").map(|s| s.as_str()), Some("csrf-value"));
1933 }
1934
1935 #[test]
1940 fn apply_chain_context_substitutes_path_headers_and_body() {
1941 let mut ctx = ChainContext::default();
1942 ctx.vars.insert("user".to_string(), "alice".to_string());
1943 ctx.cookies.insert("sid".to_string(), "deadbeef".to_string());
1944 let check = ConformanceCheck {
1945 name: "custom:t".into(),
1946 method: Method::POST,
1947 path: "/users/${var:user}".into(),
1948 headers: vec![("Cookie".into(), "sid=${cookie:sid}".into())],
1949 body: Some(CheckBody::Json(serde_json::json!({"by": "${var:user}", "ts": 1}))),
1950 validation: CheckValidation::ExactStatus(200),
1951 };
1952 let substituted = apply_chain_context(&check, &ctx);
1953 assert_eq!(substituted.path, "/users/alice");
1954 assert_eq!(substituted.headers[0].1, "sid=deadbeef");
1955 match substituted.body {
1956 Some(CheckBody::Json(v)) => {
1957 assert_eq!(v["by"], "alice");
1958 assert_eq!(v["ts"], 1);
1959 }
1960 _ => panic!("expected json body"),
1961 }
1962 }
1963
1964 #[test]
1965 fn test_reference_check_count() {
1966 let config = ConformanceConfig {
1967 target_url: "http://localhost:3000".to_string(),
1968 ..Default::default()
1969 };
1970 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1971 assert_eq!(executor.check_count(), 47);
1974 }
1975
1976 #[test]
1977 fn with_custom_checks_from_config_appends() {
1978 let custom_yaml = r#"
1983custom_checks:
1984 - name: "custom:health"
1985 path: /health
1986 method: GET
1987 expected_status: 200
1988 - name: "custom:create"
1989 path: /widgets
1990 method: POST
1991 expected_status: 201
1992"#;
1993 let parsed: CustomConformanceConfig =
1994 serde_yaml::from_str(custom_yaml).expect("YAML parses");
1995 assert_eq!(parsed.custom_checks.len(), 2);
1996
1997 let base = ConformanceConfig {
1998 target_url: "http://localhost:3000".to_string(),
1999 ..Default::default()
2000 };
2001 let executor = NativeConformanceExecutor::new(base)
2002 .unwrap()
2003 .with_reference_checks()
2004 .with_custom_checks_from_config(parsed)
2005 .expect("custom checks load");
2006 assert_eq!(executor.check_count(), 49);
2008 }
2009
2010 #[test]
2011 fn with_custom_checks_from_config_respects_filter() {
2012 let custom_yaml = r#"
2015custom_checks:
2016 - name: "custom:health"
2017 path: /health
2018 method: GET
2019 expected_status: 200
2020 - name: "custom:create-widget"
2021 path: /widgets
2022 method: POST
2023 expected_status: 201
2024"#;
2025 let parsed: CustomConformanceConfig =
2026 serde_yaml::from_str(custom_yaml).expect("YAML parses");
2027
2028 let base = ConformanceConfig {
2029 target_url: "http://localhost:3000".to_string(),
2030 categories: Some(vec!["no_such_category".to_string()]),
2033 custom_filter: Some("health".to_string()),
2034 ..Default::default()
2035 };
2036 let executor = NativeConformanceExecutor::new(base)
2037 .unwrap()
2038 .with_reference_checks()
2039 .with_custom_checks_from_config(parsed)
2040 .expect("custom checks load");
2041 assert_eq!(executor.check_count(), 1);
2044 }
2045
2046 #[test]
2047 fn with_custom_checks_from_config_rejects_bad_filter_regex() {
2048 let parsed: CustomConformanceConfig =
2049 serde_yaml::from_str("custom_checks: []").expect("YAML parses");
2050 let base = ConformanceConfig {
2051 target_url: "http://localhost:3000".to_string(),
2052 custom_filter: Some("[unclosed".to_string()),
2053 ..Default::default()
2054 };
2055 let result = NativeConformanceExecutor::new(base)
2056 .unwrap()
2057 .with_reference_checks()
2058 .with_custom_checks_from_config(parsed);
2059 assert!(result.is_err(), "bad regex should bubble up as BenchError");
2060 }
2061
2062 #[test]
2063 fn test_reference_checks_with_category_filter() {
2064 let config = ConformanceConfig {
2065 target_url: "http://localhost:3000".to_string(),
2066 categories: Some(vec!["Parameters".to_string()]),
2067 ..Default::default()
2068 };
2069 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
2070 assert_eq!(executor.check_count(), 7);
2071 }
2072
2073 #[test]
2074 fn test_validate_status_range() {
2075 let config = ConformanceConfig {
2076 target_url: "http://localhost:3000".to_string(),
2077 ..Default::default()
2078 };
2079 let executor = NativeConformanceExecutor::new(config).unwrap();
2080 let headers = HashMap::new();
2081
2082 assert!(
2083 executor
2084 .validate_response(
2085 &CheckValidation::StatusRange {
2086 min: 200,
2087 max_exclusive: 500,
2088 },
2089 200,
2090 &headers,
2091 "",
2092 )
2093 .0
2094 );
2095 assert!(
2096 executor
2097 .validate_response(
2098 &CheckValidation::StatusRange {
2099 min: 200,
2100 max_exclusive: 500,
2101 },
2102 404,
2103 &headers,
2104 "",
2105 )
2106 .0
2107 );
2108 assert!(
2109 !executor
2110 .validate_response(
2111 &CheckValidation::StatusRange {
2112 min: 200,
2113 max_exclusive: 500,
2114 },
2115 500,
2116 &headers,
2117 "",
2118 )
2119 .0
2120 );
2121 }
2122
2123 #[test]
2124 fn test_validate_exact_status() {
2125 let config = ConformanceConfig {
2126 target_url: "http://localhost:3000".to_string(),
2127 ..Default::default()
2128 };
2129 let executor = NativeConformanceExecutor::new(config).unwrap();
2130 let headers = HashMap::new();
2131
2132 assert!(
2133 executor
2134 .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
2135 .0
2136 );
2137 assert!(
2138 !executor
2139 .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
2140 .0
2141 );
2142 }
2143
2144 #[test]
2145 fn test_validate_schema() {
2146 let config = ConformanceConfig {
2147 target_url: "http://localhost:3000".to_string(),
2148 ..Default::default()
2149 };
2150 let executor = NativeConformanceExecutor::new(config).unwrap();
2151 let headers = HashMap::new();
2152
2153 let schema = serde_json::json!({
2154 "type": "object",
2155 "properties": {
2156 "name": {"type": "string"},
2157 "age": {"type": "integer"}
2158 },
2159 "required": ["name"]
2160 });
2161
2162 let (passed, violations) = executor.validate_response(
2163 &CheckValidation::SchemaValidation {
2164 status_min: 200,
2165 status_max: 300,
2166 schema: schema.clone(),
2167 },
2168 200,
2169 &headers,
2170 r#"{"name": "test", "age": 25}"#,
2171 );
2172 assert!(passed);
2173 assert!(violations.is_empty());
2174
2175 let (passed, violations) = executor.validate_response(
2177 &CheckValidation::SchemaValidation {
2178 status_min: 200,
2179 status_max: 300,
2180 schema: schema.clone(),
2181 },
2182 200,
2183 &headers,
2184 r#"{"age": 25}"#,
2185 );
2186 assert!(!passed);
2187 assert!(!violations.is_empty());
2188 assert_eq!(violations[0].violation_type, "Required");
2189 }
2190
2191 #[test]
2192 fn test_validate_custom() {
2193 let config = ConformanceConfig {
2194 target_url: "http://localhost:3000".to_string(),
2195 ..Default::default()
2196 };
2197 let executor = NativeConformanceExecutor::new(config).unwrap();
2198 let mut headers = HashMap::new();
2199 headers.insert("content-type".to_string(), "application/json".to_string());
2200
2201 assert!(
2202 executor
2203 .validate_response(
2204 &CheckValidation::Custom {
2205 expected_status: 200,
2206 expected_headers: vec![(
2207 "content-type".to_string(),
2208 "application/json".to_string(),
2209 )],
2210 expected_body_fields: vec![("name".to_string(), "string".to_string())],
2211 },
2212 200,
2213 &headers,
2214 r#"{"name": "test"}"#,
2215 )
2216 .0
2217 );
2218
2219 assert!(
2221 !executor
2222 .validate_response(
2223 &CheckValidation::Custom {
2224 expected_status: 200,
2225 expected_headers: vec![],
2226 expected_body_fields: vec![],
2227 },
2228 404,
2229 &headers,
2230 "",
2231 )
2232 .0
2233 );
2234 }
2235
2236 #[test]
2237 fn test_aggregate_results() {
2238 let results = vec![
2239 CheckResult {
2240 name: "check1".to_string(),
2241 passed: true,
2242 failure_detail: None,
2243 captured: None,
2244 },
2245 CheckResult {
2246 name: "check2".to_string(),
2247 passed: false,
2248 captured: None,
2249 failure_detail: Some(FailureDetail {
2250 check: "check2".to_string(),
2251 request: FailureRequest {
2252 method: "GET".to_string(),
2253 url: "http://example.com".to_string(),
2254 headers: HashMap::new(),
2255 body: String::new(),
2256 },
2257 response: FailureResponse {
2258 status: 500,
2259 headers: HashMap::new(),
2260 body: "error".to_string(),
2261 },
2262 expected: "status >= 200 && status < 500".to_string(),
2263 schema_violations: Vec::new(),
2264 }),
2265 },
2266 ];
2267
2268 let report = NativeConformanceExecutor::aggregate(results);
2269 let raw = report.raw_check_results();
2270 assert_eq!(raw.get("check1"), Some(&(1, 0)));
2271 assert_eq!(raw.get("check2"), Some(&(0, 1)));
2272 }
2273
2274 #[test]
2275 fn test_custom_check_building() {
2276 let config = ConformanceConfig {
2277 target_url: "http://localhost:3000".to_string(),
2278 ..Default::default()
2279 };
2280 let mut executor = NativeConformanceExecutor::new(config).unwrap();
2281
2282 let custom = CustomCheck {
2283 name: "custom:test-get".to_string(),
2284 path: "/api/test".to_string(),
2285 method: "GET".to_string(),
2286 expected_status: 200,
2287 body: None,
2288 expected_headers: HashMap::new(),
2289 expected_body_fields: vec![],
2290 headers: HashMap::new(),
2291 upload: None,
2292 uploads: vec![],
2293 extract: crate::conformance::custom::ExtractRules::default(),
2294 repeat: crate::conformance::custom::Repeat::default(),
2295 };
2296
2297 executor.add_custom_check(&custom);
2298 assert_eq!(executor.check_count(), 1);
2299 assert_eq!(executor.checks[0].name, "custom:test-get");
2300 }
2301
2302 #[test]
2303 fn test_openapi_schema_to_json_schema_object() {
2304 use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
2305
2306 let schema = Schema {
2307 schema_data: SchemaData::default(),
2308 schema_kind: SchemaKind::Type(Type::Object(ObjectType {
2309 required: vec!["name".to_string()],
2310 ..Default::default()
2311 })),
2312 };
2313
2314 let json = openapi_schema_to_json_schema(&schema);
2315 assert_eq!(json["type"], "object");
2316 assert_eq!(json["required"][0], "name");
2317 }
2318}