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}
61
62#[derive(Debug, Clone)]
64pub enum CheckValidation {
65 StatusRange { min: u16, max_exclusive: u16 },
67 ExactStatus(u16),
69 SchemaValidation {
71 status_min: u16,
72 status_max: u16,
73 schema: serde_json::Value,
74 },
75 Custom {
77 expected_status: u16,
78 expected_headers: Vec<(String, String)>,
79 expected_body_fields: Vec<(String, String)>,
80 },
81}
82
83#[derive(Debug, Clone, serde::Serialize)]
85#[serde(tag = "type")]
86pub enum ConformanceProgress {
87 #[serde(rename = "started")]
89 Started { total_checks: usize },
90 #[serde(rename = "check_completed")]
92 CheckCompleted {
93 name: String,
94 passed: bool,
95 checks_done: usize,
96 },
97 #[serde(rename = "finished")]
99 Finished,
100 #[serde(rename = "error")]
102 Error { message: String },
103}
104
105#[derive(Debug)]
107struct CheckResult {
108 name: String,
109 passed: bool,
110 failure_detail: Option<FailureDetail>,
111 captured: Option<CapturedExchange>,
114}
115
116#[derive(Debug, serde::Serialize)]
118struct CapturedExchange {
119 method: String,
120 url: String,
121 request_headers: HashMap<String, String>,
122 request_body: String,
123 response_status: u16,
124 response_headers: HashMap<String, String>,
125 response_body: String,
126}
127
128pub struct NativeConformanceExecutor {
130 config: ConformanceConfig,
131 client: Client,
132 checks: Vec<ConformanceCheck>,
133}
134
135impl NativeConformanceExecutor {
136 pub fn new(config: ConformanceConfig) -> Result<Self> {
138 let mut builder = Client::builder()
139 .timeout(Duration::from_secs(30))
140 .connect_timeout(Duration::from_secs(10));
141
142 if config.skip_tls_verify {
143 builder = builder.danger_accept_invalid_certs(true);
144 }
145
146 let client = builder
147 .build()
148 .map_err(|e| BenchError::Other(format!("Failed to build HTTP client: {}", e)))?;
149
150 Ok(Self {
151 config,
152 client,
153 checks: Vec::new(),
154 })
155 }
156
157 #[must_use]
160 pub fn with_reference_checks(mut self) -> Self {
161 if self.config.should_include_category("Parameters") {
163 self.add_ref_get("param:path:string", "/conformance/params/hello");
164 self.add_ref_get("param:path:integer", "/conformance/params/42");
165 self.add_ref_get("param:query:string", "/conformance/params/query?name=test");
166 self.add_ref_get("param:query:integer", "/conformance/params/query?count=10");
167 self.add_ref_get("param:query:array", "/conformance/params/query?tags=a&tags=b");
168 self.checks.push(ConformanceCheck {
169 name: "param:header".to_string(),
170 method: Method::GET,
171 path: "/conformance/params/header".to_string(),
172 headers: self
173 .merge_headers(vec![("X-Custom-Param".to_string(), "test-value".to_string())]),
174 body: None,
175 validation: CheckValidation::StatusRange {
176 min: 200,
177 max_exclusive: 500,
178 },
179 });
180 self.checks.push(ConformanceCheck {
181 name: "param:cookie".to_string(),
182 method: Method::GET,
183 path: "/conformance/params/cookie".to_string(),
184 headers: self
185 .merge_headers(vec![("Cookie".to_string(), "session=abc123".to_string())]),
186 body: None,
187 validation: CheckValidation::StatusRange {
188 min: 200,
189 max_exclusive: 500,
190 },
191 });
192 }
193
194 if self.config.should_include_category("Request Bodies") {
196 self.checks.push(ConformanceCheck {
197 name: "body:json".to_string(),
198 method: Method::POST,
199 path: "/conformance/body/json".to_string(),
200 headers: self.merge_headers(vec![(
201 "Content-Type".to_string(),
202 "application/json".to_string(),
203 )]),
204 body: Some(CheckBody::Json(serde_json::json!({"name": "test", "value": 42}))),
205 validation: CheckValidation::StatusRange {
206 min: 200,
207 max_exclusive: 500,
208 },
209 });
210 self.checks.push(ConformanceCheck {
211 name: "body:form-urlencoded".to_string(),
212 method: Method::POST,
213 path: "/conformance/body/form".to_string(),
214 headers: self.custom_headers_only(),
215 body: Some(CheckBody::FormUrlencoded(vec![
216 ("field1".to_string(), "value1".to_string()),
217 ("field2".to_string(), "value2".to_string()),
218 ])),
219 validation: CheckValidation::StatusRange {
220 min: 200,
221 max_exclusive: 500,
222 },
223 });
224 self.checks.push(ConformanceCheck {
225 name: "body:multipart".to_string(),
226 method: Method::POST,
227 path: "/conformance/body/multipart".to_string(),
228 headers: self.custom_headers_only(),
229 body: Some(CheckBody::Raw {
230 content: "test content".to_string(),
231 content_type: "text/plain".to_string(),
232 }),
233 validation: CheckValidation::StatusRange {
234 min: 200,
235 max_exclusive: 500,
236 },
237 });
238 }
239
240 if self.config.should_include_category("Schema Types") {
242 let types = [
243 ("string", r#"{"value": "hello"}"#, "schema:string"),
244 ("integer", r#"{"value": 42}"#, "schema:integer"),
245 ("number", r#"{"value": 3.14}"#, "schema:number"),
246 ("boolean", r#"{"value": true}"#, "schema:boolean"),
247 ("array", r#"{"value": [1, 2, 3]}"#, "schema:array"),
248 ("object", r#"{"value": {"nested": "data"}}"#, "schema:object"),
249 ];
250 for (type_name, body_str, check_name) in types {
251 self.checks.push(ConformanceCheck {
252 name: check_name.to_string(),
253 method: Method::POST,
254 path: format!("/conformance/schema/{}", type_name),
255 headers: self.merge_headers(vec![(
256 "Content-Type".to_string(),
257 "application/json".to_string(),
258 )]),
259 body: Some(CheckBody::Json(
260 serde_json::from_str(body_str).expect("valid JSON"),
261 )),
262 validation: CheckValidation::StatusRange {
263 min: 200,
264 max_exclusive: 500,
265 },
266 });
267 }
268 }
269
270 if self.config.should_include_category("Composition") {
272 let compositions = [
273 ("oneOf", r#"{"type": "string", "value": "test"}"#, "composition:oneOf"),
274 ("anyOf", r#"{"value": "test"}"#, "composition:anyOf"),
275 ("allOf", r#"{"name": "test", "id": 1}"#, "composition:allOf"),
276 ];
277 for (kind, body_str, check_name) in compositions {
278 self.checks.push(ConformanceCheck {
279 name: check_name.to_string(),
280 method: Method::POST,
281 path: format!("/conformance/composition/{}", kind),
282 headers: self.merge_headers(vec![(
283 "Content-Type".to_string(),
284 "application/json".to_string(),
285 )]),
286 body: Some(CheckBody::Json(
287 serde_json::from_str(body_str).expect("valid JSON"),
288 )),
289 validation: CheckValidation::StatusRange {
290 min: 200,
291 max_exclusive: 500,
292 },
293 });
294 }
295 }
296
297 if self.config.should_include_category("String Formats") {
299 let formats = [
300 ("date", r#"{"value": "2024-01-15"}"#, "format:date"),
301 ("date-time", r#"{"value": "2024-01-15T10:30:00Z"}"#, "format:date-time"),
302 ("email", r#"{"value": "test@example.com"}"#, "format:email"),
303 ("uuid", r#"{"value": "550e8400-e29b-41d4-a716-446655440000"}"#, "format:uuid"),
304 ("uri", r#"{"value": "https://example.com/path"}"#, "format:uri"),
305 ("ipv4", r#"{"value": "192.168.1.1"}"#, "format:ipv4"),
306 ("ipv6", r#"{"value": "::1"}"#, "format:ipv6"),
307 ];
308 for (fmt, body_str, check_name) in formats {
309 self.checks.push(ConformanceCheck {
310 name: check_name.to_string(),
311 method: Method::POST,
312 path: format!("/conformance/formats/{}", fmt),
313 headers: self.merge_headers(vec![(
314 "Content-Type".to_string(),
315 "application/json".to_string(),
316 )]),
317 body: Some(CheckBody::Json(
318 serde_json::from_str(body_str).expect("valid JSON"),
319 )),
320 validation: CheckValidation::StatusRange {
321 min: 200,
322 max_exclusive: 500,
323 },
324 });
325 }
326 }
327
328 if self.config.should_include_category("Constraints") {
330 let constraints = [
331 ("required", r#"{"required_field": "present"}"#, "constraint:required"),
332 ("optional", r#"{}"#, "constraint:optional"),
333 ("minmax", r#"{"value": 50}"#, "constraint:minmax"),
334 ("pattern", r#"{"value": "ABC-123"}"#, "constraint:pattern"),
335 ("enum", r#"{"status": "active"}"#, "constraint:enum"),
336 ];
337 for (kind, body_str, check_name) in constraints {
338 self.checks.push(ConformanceCheck {
339 name: check_name.to_string(),
340 method: Method::POST,
341 path: format!("/conformance/constraints/{}", kind),
342 headers: self.merge_headers(vec![(
343 "Content-Type".to_string(),
344 "application/json".to_string(),
345 )]),
346 body: Some(CheckBody::Json(
347 serde_json::from_str(body_str).expect("valid JSON"),
348 )),
349 validation: CheckValidation::StatusRange {
350 min: 200,
351 max_exclusive: 500,
352 },
353 });
354 }
355 }
356
357 if self.config.should_include_category("Response Codes") {
359 for (code_str, check_name) in [
360 ("200", "response:200"),
361 ("201", "response:201"),
362 ("204", "response:204"),
363 ("400", "response:400"),
364 ("404", "response:404"),
365 ] {
366 let code: u16 = code_str.parse().unwrap();
367 self.checks.push(ConformanceCheck {
368 name: check_name.to_string(),
369 method: Method::GET,
370 path: format!("/conformance/responses/{}", code_str),
371 headers: self.custom_headers_only(),
372 body: None,
373 validation: CheckValidation::ExactStatus(code),
374 });
375 }
376 }
377
378 if self.config.should_include_category("HTTP Methods") {
380 self.add_ref_get("method:GET", "/conformance/methods");
381 for (method, check_name) in [
382 (Method::POST, "method:POST"),
383 (Method::PUT, "method:PUT"),
384 (Method::PATCH, "method:PATCH"),
385 ] {
386 self.checks.push(ConformanceCheck {
387 name: check_name.to_string(),
388 method,
389 path: "/conformance/methods".to_string(),
390 headers: self.merge_headers(vec![(
391 "Content-Type".to_string(),
392 "application/json".to_string(),
393 )]),
394 body: Some(CheckBody::Json(serde_json::json!({"action": "test"}))),
395 validation: CheckValidation::StatusRange {
396 min: 200,
397 max_exclusive: 500,
398 },
399 });
400 }
401 for (method, check_name) in [
402 (Method::DELETE, "method:DELETE"),
403 (Method::HEAD, "method:HEAD"),
404 (Method::OPTIONS, "method:OPTIONS"),
405 ] {
406 self.checks.push(ConformanceCheck {
407 name: check_name.to_string(),
408 method,
409 path: "/conformance/methods".to_string(),
410 headers: self.custom_headers_only(),
411 body: None,
412 validation: CheckValidation::StatusRange {
413 min: 200,
414 max_exclusive: 500,
415 },
416 });
417 }
418 }
419
420 if self.config.should_include_category("Content Types") {
422 self.checks.push(ConformanceCheck {
423 name: "content:negotiation".to_string(),
424 method: Method::GET,
425 path: "/conformance/content-types".to_string(),
426 headers: self
427 .merge_headers(vec![("Accept".to_string(), "application/json".to_string())]),
428 body: None,
429 validation: CheckValidation::StatusRange {
430 min: 200,
431 max_exclusive: 500,
432 },
433 });
434 }
435
436 if self.config.should_include_category("Security") {
438 self.checks.push(ConformanceCheck {
440 name: "security:bearer".to_string(),
441 method: Method::GET,
442 path: "/conformance/security/bearer".to_string(),
443 headers: self.merge_headers(vec![(
444 "Authorization".to_string(),
445 "Bearer test-token-123".to_string(),
446 )]),
447 body: None,
448 validation: CheckValidation::StatusRange {
449 min: 200,
450 max_exclusive: 500,
451 },
452 });
453
454 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
456 self.checks.push(ConformanceCheck {
457 name: "security:apikey".to_string(),
458 method: Method::GET,
459 path: "/conformance/security/apikey".to_string(),
460 headers: self.merge_headers(vec![("X-API-Key".to_string(), api_key.to_string())]),
461 body: None,
462 validation: CheckValidation::StatusRange {
463 min: 200,
464 max_exclusive: 500,
465 },
466 });
467
468 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
470 use base64::Engine;
471 let encoded = base64::engine::general_purpose::STANDARD.encode(basic_creds.as_bytes());
472 self.checks.push(ConformanceCheck {
473 name: "security:basic".to_string(),
474 method: Method::GET,
475 path: "/conformance/security/basic".to_string(),
476 headers: self.merge_headers(vec![(
477 "Authorization".to_string(),
478 format!("Basic {}", encoded),
479 )]),
480 body: None,
481 validation: CheckValidation::StatusRange {
482 min: 200,
483 max_exclusive: 500,
484 },
485 });
486 }
487
488 self
489 }
490
491 #[must_use]
493 pub fn with_spec_driven_checks(mut self, operations: &[AnnotatedOperation]) -> Self {
494 let mut feature_seen: HashSet<&'static str> = HashSet::new();
496
497 for op in operations {
498 for feature in &op.features {
499 let category = feature.category();
500 if !self.config.should_include_category(category) {
501 continue;
502 }
503
504 let check_name_base = feature.check_name();
505
506 if self.config.all_operations {
507 let check_name = format!("{}:{}", check_name_base, op.path);
509 let check = self.build_spec_check(&check_name, op, feature);
510 self.checks.push(check);
511 } else {
512 if feature_seen.insert(check_name_base) {
514 let check_name = format!("{}:{}", check_name_base, op.path);
515 let check = self.build_spec_check(&check_name, op, feature);
516 self.checks.push(check);
517 }
518 }
519 }
520 }
521
522 self
523 }
524
525 pub fn with_custom_checks(self) -> Result<Self> {
527 let path = match &self.config.custom_checks_file {
528 Some(p) => p.clone(),
529 None => return Ok(self),
530 };
531 let custom_config = CustomConformanceConfig::from_file(&path)?;
532 self.append_custom_checks(&custom_config)
533 }
534
535 pub fn with_custom_checks_from_config(
543 self,
544 custom_config: CustomConformanceConfig,
545 ) -> Result<Self> {
546 self.append_custom_checks(&custom_config)
547 }
548
549 fn append_custom_checks(mut self, custom_config: &CustomConformanceConfig) -> Result<Self> {
554 let filter_re = match &self.config.custom_filter {
555 Some(pattern) => Some(regex::Regex::new(pattern).map_err(|e| {
556 BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
557 })?),
558 None => None,
559 };
560
561 let mut included = 0usize;
562 let total = custom_config.custom_checks.len();
563 for check in &custom_config.custom_checks {
564 if let Some(ref re) = filter_re {
565 if !re.is_match(&check.name) && !re.is_match(&check.path) {
566 continue;
567 }
568 }
569 self.add_custom_check(check);
570 included += 1;
571 }
572
573 if filter_re.is_some() {
574 tracing::info!("Custom check filter: {}/{} checks matched pattern", included, total);
575 }
576
577 Ok(self)
578 }
579
580 pub fn check_count(&self) -> usize {
582 self.checks.len()
583 }
584
585 pub async fn execute(&self) -> Result<ConformanceReport> {
587 let mut results = Vec::with_capacity(self.checks.len());
588 let delay = self.config.request_delay_ms;
589
590 for (i, check) in self.checks.iter().enumerate() {
591 if delay > 0 && i > 0 {
592 tokio::time::sleep(Duration::from_millis(delay)).await;
593 }
594 results.push(self.execute_check(check).await);
595 }
596
597 if self.config.export_requests {
599 if let Some(ref output_dir) = self.config.output_dir {
600 let request_log: Vec<_> = results
601 .iter()
602 .filter_map(|r| {
603 r.captured.as_ref().map(|c| {
604 serde_json::json!({
605 "check": r.name,
606 "passed": r.passed,
607 "request": {
608 "method": c.method,
609 "url": c.url,
610 "headers": c.request_headers,
611 "body": c.request_body,
612 },
613 "response": {
614 "status": c.response_status,
615 "headers": c.response_headers,
616 "body": c.response_body,
617 },
618 })
619 })
620 })
621 .collect();
622 let path = output_dir.join("conformance-requests.json");
623 if let Ok(json) = serde_json::to_string_pretty(&request_log) {
624 let _ = std::fs::write(&path, json);
625 tracing::info!(
626 "Exported {} request/response pairs to {}",
627 request_log.len(),
628 path.display()
629 );
630 }
631 }
632 }
633
634 Ok(Self::aggregate(results))
635 }
636
637 pub async fn execute_with_progress(
639 &self,
640 tx: mpsc::Sender<ConformanceProgress>,
641 ) -> Result<ConformanceReport> {
642 let total = self.checks.len();
643 let delay = self.config.request_delay_ms;
644 let _ = tx
645 .send(ConformanceProgress::Started {
646 total_checks: total,
647 })
648 .await;
649
650 let mut results = Vec::with_capacity(total);
651
652 for (i, check) in self.checks.iter().enumerate() {
653 if delay > 0 && i > 0 {
654 tokio::time::sleep(Duration::from_millis(delay)).await;
655 }
656 let result = self.execute_check(check).await;
657 let passed = result.passed;
658 let name = result.name.clone();
659 results.push(result);
660
661 let _ = tx
662 .send(ConformanceProgress::CheckCompleted {
663 name,
664 passed,
665 checks_done: i + 1,
666 })
667 .await;
668 }
669
670 let _ = tx.send(ConformanceProgress::Finished).await;
671 Ok(Self::aggregate(results))
672 }
673
674 async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
676 let base_url = self.config.effective_base_url();
677 let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
678
679 let mut request = self.client.request(check.method.clone(), &url);
680
681 for (name, value) in &check.headers {
683 request = request.header(name.as_str(), value.as_str());
684 }
685
686 match &check.body {
688 Some(CheckBody::Json(value)) => {
689 request = request.json(value);
690 }
691 Some(CheckBody::FormUrlencoded(fields)) => {
692 request = request.form(fields);
693 }
694 Some(CheckBody::Raw {
695 content,
696 content_type,
697 }) => {
698 if content_type == "text/plain" && check.path.contains("multipart") {
700 let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
701 .file_name("test.txt")
702 .mime_str(content_type)
703 .unwrap_or_else(|_| {
704 reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
705 });
706 let form = reqwest::multipart::Form::new().part("field", part);
707 request = request.multipart(form);
708 } else {
709 request =
710 request.header("Content-Type", content_type.as_str()).body(content.clone());
711 }
712 }
713 None => {}
714 }
715
716 let req_body_str = match &check.body {
717 Some(CheckBody::Json(v)) => v.to_string(),
718 Some(CheckBody::FormUrlencoded(f)) => {
719 f.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&")
720 }
721 Some(CheckBody::Raw { content, .. }) => content.clone(),
722 None => String::new(),
723 };
724
725 let response = match request.send().await {
726 Ok(resp) => resp,
727 Err(e) => {
728 return CheckResult {
729 name: check.name.clone(),
730 passed: false,
731 failure_detail: Some(FailureDetail {
732 check: check.name.clone(),
733 request: FailureRequest {
734 method: check.method.to_string(),
735 url: url.clone(),
736 headers: HashMap::new(),
737 body: String::new(),
738 },
739 response: FailureResponse {
740 status: 0,
741 headers: HashMap::new(),
742 body: format!("Request failed: {}", e),
743 },
744 expected: format!("{:?}", check.validation),
745 schema_violations: Vec::new(),
746 }),
747 captured: None,
748 };
749 }
750 };
751
752 let status = response.status().as_u16();
753 let resp_headers: HashMap<String, String> = response
754 .headers()
755 .iter()
756 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
757 .collect();
758 let resp_body = response.text().await.unwrap_or_default();
759
760 let (passed, schema_violations) =
761 self.validate_response(&check.validation, status, &resp_headers, &resp_body);
762
763 let captured = if self.config.export_requests {
765 Some(CapturedExchange {
766 method: check.method.to_string(),
767 url: url.clone(),
768 request_headers: check.headers.iter().cloned().collect(),
769 request_body: req_body_str,
770 response_status: status,
771 response_headers: resp_headers.clone(),
772 response_body: if resp_body.len() > 2000 {
773 format!("{}...(truncated)", &resp_body[..2000])
774 } else {
775 resp_body.clone()
776 },
777 })
778 } else {
779 None
780 };
781
782 let failure_detail = if !passed {
783 Some(FailureDetail {
784 check: check.name.clone(),
785 request: FailureRequest {
786 method: check.method.to_string(),
787 url,
788 headers: check.headers.iter().cloned().collect(),
789 body: match &check.body {
790 Some(CheckBody::Json(v)) => v.to_string(),
791 Some(CheckBody::FormUrlencoded(f)) => f
792 .iter()
793 .map(|(k, v)| format!("{}={}", k, v))
794 .collect::<Vec<_>>()
795 .join("&"),
796 Some(CheckBody::Raw { content, .. }) => content.clone(),
797 None => String::new(),
798 },
799 },
800 response: FailureResponse {
801 status,
802 headers: resp_headers,
803 body: if resp_body.len() > 500 {
804 format!("{}...", &resp_body[..500])
805 } else {
806 resp_body
807 },
808 },
809 expected: Self::describe_validation(&check.validation),
810 schema_violations,
811 })
812 } else {
813 None
814 };
815
816 CheckResult {
817 name: check.name.clone(),
818 passed,
819 failure_detail,
820 captured,
821 }
822 }
823
824 fn validate_response(
829 &self,
830 validation: &CheckValidation,
831 status: u16,
832 headers: &HashMap<String, String>,
833 body: &str,
834 ) -> (bool, Vec<SchemaViolation>) {
835 match validation {
836 CheckValidation::StatusRange { min, max_exclusive } => {
837 (status >= *min && status < *max_exclusive, Vec::new())
838 }
839 CheckValidation::ExactStatus(expected) => (status == *expected, Vec::new()),
840 CheckValidation::SchemaValidation {
841 status_min,
842 status_max,
843 schema,
844 } => {
845 if status < *status_min || status >= *status_max {
846 return (false, Vec::new());
847 }
848 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
850 return (
851 false,
852 vec![SchemaViolation {
853 field_path: "/".to_string(),
854 violation_type: "parse_error".to_string(),
855 expected: "valid JSON".to_string(),
856 actual: "non-JSON response body".to_string(),
857 }],
858 );
859 };
860 match jsonschema::validator_for(schema) {
861 Ok(validator) => {
862 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
863 if errors.is_empty() {
864 (true, Vec::new())
865 } else {
866 let violations = errors
867 .iter()
868 .map(|err| {
869 let field_path = err.instance_path.to_string();
870 let field_path = if field_path.is_empty() {
871 "/".to_string()
872 } else {
873 field_path
874 };
875 SchemaViolation {
876 field_path,
877 violation_type: format!("{:?}", err.kind)
878 .split('(')
879 .next()
880 .unwrap_or("unknown")
881 .split('{')
882 .next()
883 .unwrap_or("unknown")
884 .split(' ')
885 .next()
886 .unwrap_or("unknown")
887 .trim()
888 .to_string(),
889 expected: {
890 let schema_str = format!("{}", err.schema_path);
894 match &err.kind {
895 jsonschema::error::ValidationErrorKind::Type { kind } => {
896 format!("type: {:?}", kind)
897 }
898 jsonschema::error::ValidationErrorKind::Required { property } => {
899 format!("required field: {}", property)
900 }
901 _ => {
902 schema_str
904 .rsplit('/')
905 .next()
906 .unwrap_or(&schema_str)
907 .to_string()
908 }
909 }
910 },
911 actual: format!("{}", err),
912 }
913 })
914 .collect();
915 (false, violations)
916 }
917 }
918 Err(_) => {
919 (
921 false,
922 vec![SchemaViolation {
923 field_path: "/".to_string(),
924 violation_type: "schema_compile_error".to_string(),
925 expected: "valid JSON schema".to_string(),
926 actual: "schema failed to compile".to_string(),
927 }],
928 )
929 }
930 }
931 }
932 CheckValidation::Custom {
933 expected_status,
934 expected_headers,
935 expected_body_fields,
936 } => {
937 if status != *expected_status {
938 return (false, Vec::new());
939 }
940 for (header_name, pattern) in expected_headers {
942 let header_val = headers
943 .get(header_name)
944 .or_else(|| headers.get(&header_name.to_lowercase()))
945 .map(|s| s.as_str())
946 .unwrap_or("");
947 if let Ok(re) = regex::Regex::new(pattern) {
948 if !re.is_match(header_val) {
949 return (false, Vec::new());
950 }
951 }
952 }
953 if !expected_body_fields.is_empty() {
955 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
956 return (false, Vec::new());
957 };
958 for (field_name, field_type) in expected_body_fields {
959 let field = &body_value[field_name];
960 let ok = match field_type.as_str() {
961 "string" => field.is_string(),
962 "integer" => field.is_i64() || field.is_u64(),
963 "number" => field.is_number(),
964 "boolean" => field.is_boolean(),
965 "array" => field.is_array(),
966 "object" => field.is_object(),
967 _ => !field.is_null(),
968 };
969 if !ok {
970 return (false, Vec::new());
971 }
972 }
973 }
974 (true, Vec::new())
975 }
976 }
977 }
978
979 fn describe_validation(validation: &CheckValidation) -> String {
981 match validation {
982 CheckValidation::StatusRange { min, max_exclusive } => {
983 format!("status >= {} && status < {}", min, max_exclusive)
984 }
985 CheckValidation::ExactStatus(code) => format!("status === {}", code),
986 CheckValidation::SchemaValidation {
987 status_min,
988 status_max,
989 ..
990 } => {
991 format!("status >= {} && status < {} + schema validation", status_min, status_max)
992 }
993 CheckValidation::Custom {
994 expected_status, ..
995 } => {
996 format!("status === {}", expected_status)
997 }
998 }
999 }
1000
1001 fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
1003 let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
1004 let mut failure_details = Vec::new();
1005
1006 for result in results {
1007 let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
1008 if result.passed {
1009 entry.0 += 1;
1010 } else {
1011 entry.1 += 1;
1012 }
1013 if let Some(detail) = result.failure_detail {
1014 failure_details.push(detail);
1015 }
1016 }
1017
1018 ConformanceReport::from_results(check_results, failure_details)
1019 }
1020
1021 fn build_spec_check(
1025 &self,
1026 check_name: &str,
1027 op: &AnnotatedOperation,
1028 feature: &ConformanceFeature,
1029 ) -> ConformanceCheck {
1030 let mut url_path = op.path.clone();
1032 for (name, value) in &op.path_params {
1033 url_path = url_path.replace(&format!("{{{}}}", name), value);
1034 }
1035 if !op.query_params.is_empty() {
1037 let qs: Vec<String> =
1038 op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1039 url_path = format!("{}?{}", url_path, qs.join("&"));
1040 }
1041
1042 let mut effective_headers = self.effective_headers(&op.header_params);
1044
1045 if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
1047 let code = match feature {
1048 ConformanceFeature::Response400 => "400",
1049 ConformanceFeature::Response404 => "404",
1050 _ => unreachable!(),
1051 };
1052 effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
1053 }
1054
1055 let needs_auth = matches!(
1057 feature,
1058 ConformanceFeature::SecurityBearer
1059 | ConformanceFeature::SecurityBasic
1060 | ConformanceFeature::SecurityApiKey
1061 ) || !op.security_schemes.is_empty();
1062
1063 if needs_auth {
1064 self.inject_security_headers(&op.security_schemes, &mut effective_headers);
1065 }
1066
1067 let method = match op.method.as_str() {
1069 "GET" => Method::GET,
1070 "POST" => Method::POST,
1071 "PUT" => Method::PUT,
1072 "PATCH" => Method::PATCH,
1073 "DELETE" => Method::DELETE,
1074 "HEAD" => Method::HEAD,
1075 "OPTIONS" => Method::OPTIONS,
1076 _ => Method::GET,
1077 };
1078
1079 let body = match method {
1081 Method::POST | Method::PUT | Method::PATCH => {
1082 if let Some(sample) = &op.sample_body {
1083 let content_type =
1085 op.request_body_content_type.as_deref().unwrap_or("application/json");
1086 if !effective_headers
1087 .iter()
1088 .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1089 {
1090 effective_headers
1091 .push(("Content-Type".to_string(), content_type.to_string()));
1092 }
1093 match content_type {
1094 "application/x-www-form-urlencoded" => {
1095 let fields: Vec<(String, String)> = serde_json::from_str::<
1097 serde_json::Value,
1098 >(
1099 sample
1100 )
1101 .ok()
1102 .and_then(|v| {
1103 v.as_object().map(|obj| {
1104 obj.iter()
1105 .map(|(k, v)| {
1106 (k.clone(), v.as_str().unwrap_or("").to_string())
1107 })
1108 .collect()
1109 })
1110 })
1111 .unwrap_or_default();
1112 Some(CheckBody::FormUrlencoded(fields))
1113 }
1114 _ => {
1115 match serde_json::from_str::<serde_json::Value>(sample) {
1117 Ok(v) => Some(CheckBody::Json(v)),
1118 Err(_) => Some(CheckBody::Raw {
1119 content: sample.clone(),
1120 content_type: content_type.to_string(),
1121 }),
1122 }
1123 }
1124 }
1125 } else {
1126 None
1127 }
1128 }
1129 _ => None,
1130 };
1131
1132 let validation = self.determine_validation(feature, op);
1134
1135 ConformanceCheck {
1136 name: check_name.to_string(),
1137 method,
1138 path: url_path,
1139 headers: effective_headers,
1140 body,
1141 validation,
1142 }
1143 }
1144
1145 fn determine_validation(
1147 &self,
1148 feature: &ConformanceFeature,
1149 op: &AnnotatedOperation,
1150 ) -> CheckValidation {
1151 match feature {
1152 ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1153 ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1154 ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1155 ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1156 ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1157 ConformanceFeature::SecurityBearer
1158 | ConformanceFeature::SecurityBasic
1159 | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1160 min: 200,
1161 max_exclusive: 400,
1162 },
1163 ConformanceFeature::ResponseValidation => {
1164 if let Some(schema) = &op.response_schema {
1165 let schema_json = openapi_schema_to_json_schema(schema);
1167 CheckValidation::SchemaValidation {
1168 status_min: 200,
1169 status_max: 500,
1170 schema: schema_json,
1171 }
1172 } else {
1173 CheckValidation::StatusRange {
1174 min: 200,
1175 max_exclusive: 500,
1176 }
1177 }
1178 }
1179 _ => CheckValidation::StatusRange {
1180 min: 200,
1181 max_exclusive: 500,
1182 },
1183 }
1184 }
1185
1186 fn add_ref_get(&mut self, name: &str, path: &str) {
1188 self.checks.push(ConformanceCheck {
1189 name: name.to_string(),
1190 method: Method::GET,
1191 path: path.to_string(),
1192 headers: self.custom_headers_only(),
1193 body: None,
1194 validation: CheckValidation::StatusRange {
1195 min: 200,
1196 max_exclusive: 500,
1197 },
1198 });
1199 }
1200
1201 fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1203 let mut headers = Vec::new();
1204 for (k, v) in spec_headers {
1205 if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1207 continue;
1208 }
1209 headers.push((k.clone(), v.clone()));
1210 }
1211 headers.extend(self.config.custom_headers.clone());
1213 headers
1214 }
1215
1216 fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1218 for (k, v) in &self.config.custom_headers {
1219 if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1220 headers.push((k.clone(), v.clone()));
1221 }
1222 }
1223 headers
1224 }
1225
1226 fn custom_headers_only(&self) -> Vec<(String, String)> {
1228 self.config.custom_headers.clone()
1229 }
1230
1231 fn inject_security_headers(
1235 &self,
1236 schemes: &[SecuritySchemeInfo],
1237 headers: &mut Vec<(String, String)>,
1238 ) {
1239 let has_cookie_auth =
1241 self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1242 let mut to_add: Vec<(String, String)> = Vec::new();
1243
1244 for scheme in schemes {
1245 match scheme {
1246 SecuritySchemeInfo::Bearer => {
1247 if !has_cookie_auth
1248 && !Self::header_present(
1249 "Authorization",
1250 headers,
1251 &self.config.custom_headers,
1252 )
1253 {
1254 to_add.push((
1255 "Authorization".to_string(),
1256 "Bearer mockforge-conformance-test-token".to_string(),
1257 ));
1258 }
1259 }
1260 SecuritySchemeInfo::Basic => {
1261 if !has_cookie_auth
1262 && !Self::header_present(
1263 "Authorization",
1264 headers,
1265 &self.config.custom_headers,
1266 )
1267 {
1268 let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1269 use base64::Engine;
1270 let encoded =
1271 base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1272 to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1273 }
1274 }
1275 SecuritySchemeInfo::ApiKey { location, name } => match location {
1276 ApiKeyLocation::Header => {
1277 if !Self::header_present(name, headers, &self.config.custom_headers) {
1278 let key = self
1279 .config
1280 .api_key
1281 .as_deref()
1282 .unwrap_or("mockforge-conformance-test-key");
1283 to_add.push((name.clone(), key.to_string()));
1284 }
1285 }
1286 ApiKeyLocation::Cookie => {
1287 if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1288 to_add.push((
1289 "Cookie".to_string(),
1290 format!("{}=mockforge-conformance-test-session", name),
1291 ));
1292 }
1293 }
1294 ApiKeyLocation::Query => {
1295 }
1297 },
1298 }
1299 }
1300
1301 headers.extend(to_add);
1302 }
1303
1304 fn header_present(
1306 name: &str,
1307 headers: &[(String, String)],
1308 custom_headers: &[(String, String)],
1309 ) -> bool {
1310 headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1311 || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1312 }
1313
1314 fn add_custom_check(&mut self, check: &CustomCheck) {
1316 let method = match check.method.to_uppercase().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 mut headers: Vec<(String, String)> =
1329 check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1330 for (k, v) in &self.config.custom_headers {
1332 if !check.headers.contains_key(k) {
1333 headers.push((k.clone(), v.clone()));
1334 }
1335 }
1336 if check.body.is_some()
1338 && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1339 {
1340 headers.push(("Content-Type".to_string(), "application/json".to_string()));
1341 }
1342
1343 let body = check
1345 .body
1346 .as_ref()
1347 .and_then(|b| serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json));
1348
1349 let expected_headers: Vec<(String, String)> =
1351 check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1352
1353 let expected_body_fields: Vec<(String, String)> = check
1355 .expected_body_fields
1356 .iter()
1357 .map(|f| (f.name.clone(), f.field_type.clone()))
1358 .collect();
1359
1360 self.checks.push(ConformanceCheck {
1362 name: check.name.clone(),
1363 method,
1364 path: check.path.clone(),
1365 headers,
1366 body,
1367 validation: CheckValidation::Custom {
1368 expected_status: check.expected_status,
1369 expected_headers,
1370 expected_body_fields,
1371 },
1372 });
1373 }
1374}
1375
1376fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1379 use openapiv3::{SchemaKind, Type};
1380
1381 match &schema.schema_kind {
1382 SchemaKind::Type(Type::Object(obj)) => {
1383 let mut props = serde_json::Map::new();
1384 for (name, prop_ref) in &obj.properties {
1385 if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1386 props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1387 }
1388 }
1389 let mut schema_obj = serde_json::json!({
1390 "type": "object",
1391 "properties": props,
1392 });
1393 if !obj.required.is_empty() {
1394 schema_obj["required"] = serde_json::Value::Array(
1395 obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1396 );
1397 }
1398 schema_obj
1399 }
1400 SchemaKind::Type(Type::Array(arr)) => {
1401 let mut schema_obj = serde_json::json!({"type": "array"});
1402 if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1403 schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1404 }
1405 schema_obj
1406 }
1407 SchemaKind::Type(Type::String(s)) => {
1408 let mut obj = serde_json::json!({"type": "string"});
1409 if let Some(min) = s.min_length {
1410 obj["minLength"] = serde_json::json!(min);
1411 }
1412 if let Some(max) = s.max_length {
1413 obj["maxLength"] = serde_json::json!(max);
1414 }
1415 if let Some(pattern) = &s.pattern {
1416 obj["pattern"] = serde_json::json!(pattern);
1417 }
1418 if !s.enumeration.is_empty() {
1419 obj["enum"] = serde_json::Value::Array(
1420 s.enumeration
1421 .iter()
1422 .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1423 .collect(),
1424 );
1425 }
1426 obj
1427 }
1428 SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1429 SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1430 SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1431 _ => serde_json::json!({}),
1432 }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437 use super::*;
1438
1439 #[test]
1440 fn test_reference_check_count() {
1441 let config = ConformanceConfig {
1442 target_url: "http://localhost:3000".to_string(),
1443 ..Default::default()
1444 };
1445 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1446 assert_eq!(executor.check_count(), 47);
1449 }
1450
1451 #[test]
1452 fn with_custom_checks_from_config_appends() {
1453 let custom_yaml = r#"
1458custom_checks:
1459 - name: "custom:health"
1460 path: /health
1461 method: GET
1462 expected_status: 200
1463 - name: "custom:create"
1464 path: /widgets
1465 method: POST
1466 expected_status: 201
1467"#;
1468 let parsed: CustomConformanceConfig =
1469 serde_yaml::from_str(custom_yaml).expect("YAML parses");
1470 assert_eq!(parsed.custom_checks.len(), 2);
1471
1472 let base = ConformanceConfig {
1473 target_url: "http://localhost:3000".to_string(),
1474 ..Default::default()
1475 };
1476 let executor = NativeConformanceExecutor::new(base)
1477 .unwrap()
1478 .with_reference_checks()
1479 .with_custom_checks_from_config(parsed)
1480 .expect("custom checks load");
1481 assert_eq!(executor.check_count(), 49);
1483 }
1484
1485 #[test]
1486 fn with_custom_checks_from_config_respects_filter() {
1487 let custom_yaml = r#"
1490custom_checks:
1491 - name: "custom:health"
1492 path: /health
1493 method: GET
1494 expected_status: 200
1495 - name: "custom:create-widget"
1496 path: /widgets
1497 method: POST
1498 expected_status: 201
1499"#;
1500 let parsed: CustomConformanceConfig =
1501 serde_yaml::from_str(custom_yaml).expect("YAML parses");
1502
1503 let base = ConformanceConfig {
1504 target_url: "http://localhost:3000".to_string(),
1505 categories: Some(vec!["no_such_category".to_string()]),
1508 custom_filter: Some("health".to_string()),
1509 ..Default::default()
1510 };
1511 let executor = NativeConformanceExecutor::new(base)
1512 .unwrap()
1513 .with_reference_checks()
1514 .with_custom_checks_from_config(parsed)
1515 .expect("custom checks load");
1516 assert_eq!(executor.check_count(), 1);
1519 }
1520
1521 #[test]
1522 fn with_custom_checks_from_config_rejects_bad_filter_regex() {
1523 let parsed: CustomConformanceConfig =
1524 serde_yaml::from_str("custom_checks: []").expect("YAML parses");
1525 let base = ConformanceConfig {
1526 target_url: "http://localhost:3000".to_string(),
1527 custom_filter: Some("[unclosed".to_string()),
1528 ..Default::default()
1529 };
1530 let result = NativeConformanceExecutor::new(base)
1531 .unwrap()
1532 .with_reference_checks()
1533 .with_custom_checks_from_config(parsed);
1534 assert!(result.is_err(), "bad regex should bubble up as BenchError");
1535 }
1536
1537 #[test]
1538 fn test_reference_checks_with_category_filter() {
1539 let config = ConformanceConfig {
1540 target_url: "http://localhost:3000".to_string(),
1541 categories: Some(vec!["Parameters".to_string()]),
1542 ..Default::default()
1543 };
1544 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1545 assert_eq!(executor.check_count(), 7);
1546 }
1547
1548 #[test]
1549 fn test_validate_status_range() {
1550 let config = ConformanceConfig {
1551 target_url: "http://localhost:3000".to_string(),
1552 ..Default::default()
1553 };
1554 let executor = NativeConformanceExecutor::new(config).unwrap();
1555 let headers = HashMap::new();
1556
1557 assert!(
1558 executor
1559 .validate_response(
1560 &CheckValidation::StatusRange {
1561 min: 200,
1562 max_exclusive: 500,
1563 },
1564 200,
1565 &headers,
1566 "",
1567 )
1568 .0
1569 );
1570 assert!(
1571 executor
1572 .validate_response(
1573 &CheckValidation::StatusRange {
1574 min: 200,
1575 max_exclusive: 500,
1576 },
1577 404,
1578 &headers,
1579 "",
1580 )
1581 .0
1582 );
1583 assert!(
1584 !executor
1585 .validate_response(
1586 &CheckValidation::StatusRange {
1587 min: 200,
1588 max_exclusive: 500,
1589 },
1590 500,
1591 &headers,
1592 "",
1593 )
1594 .0
1595 );
1596 }
1597
1598 #[test]
1599 fn test_validate_exact_status() {
1600 let config = ConformanceConfig {
1601 target_url: "http://localhost:3000".to_string(),
1602 ..Default::default()
1603 };
1604 let executor = NativeConformanceExecutor::new(config).unwrap();
1605 let headers = HashMap::new();
1606
1607 assert!(
1608 executor
1609 .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
1610 .0
1611 );
1612 assert!(
1613 !executor
1614 .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
1615 .0
1616 );
1617 }
1618
1619 #[test]
1620 fn test_validate_schema() {
1621 let config = ConformanceConfig {
1622 target_url: "http://localhost:3000".to_string(),
1623 ..Default::default()
1624 };
1625 let executor = NativeConformanceExecutor::new(config).unwrap();
1626 let headers = HashMap::new();
1627
1628 let schema = serde_json::json!({
1629 "type": "object",
1630 "properties": {
1631 "name": {"type": "string"},
1632 "age": {"type": "integer"}
1633 },
1634 "required": ["name"]
1635 });
1636
1637 let (passed, violations) = executor.validate_response(
1638 &CheckValidation::SchemaValidation {
1639 status_min: 200,
1640 status_max: 300,
1641 schema: schema.clone(),
1642 },
1643 200,
1644 &headers,
1645 r#"{"name": "test", "age": 25}"#,
1646 );
1647 assert!(passed);
1648 assert!(violations.is_empty());
1649
1650 let (passed, violations) = executor.validate_response(
1652 &CheckValidation::SchemaValidation {
1653 status_min: 200,
1654 status_max: 300,
1655 schema: schema.clone(),
1656 },
1657 200,
1658 &headers,
1659 r#"{"age": 25}"#,
1660 );
1661 assert!(!passed);
1662 assert!(!violations.is_empty());
1663 assert_eq!(violations[0].violation_type, "Required");
1664 }
1665
1666 #[test]
1667 fn test_validate_custom() {
1668 let config = ConformanceConfig {
1669 target_url: "http://localhost:3000".to_string(),
1670 ..Default::default()
1671 };
1672 let executor = NativeConformanceExecutor::new(config).unwrap();
1673 let mut headers = HashMap::new();
1674 headers.insert("content-type".to_string(), "application/json".to_string());
1675
1676 assert!(
1677 executor
1678 .validate_response(
1679 &CheckValidation::Custom {
1680 expected_status: 200,
1681 expected_headers: vec![(
1682 "content-type".to_string(),
1683 "application/json".to_string(),
1684 )],
1685 expected_body_fields: vec![("name".to_string(), "string".to_string())],
1686 },
1687 200,
1688 &headers,
1689 r#"{"name": "test"}"#,
1690 )
1691 .0
1692 );
1693
1694 assert!(
1696 !executor
1697 .validate_response(
1698 &CheckValidation::Custom {
1699 expected_status: 200,
1700 expected_headers: vec![],
1701 expected_body_fields: vec![],
1702 },
1703 404,
1704 &headers,
1705 "",
1706 )
1707 .0
1708 );
1709 }
1710
1711 #[test]
1712 fn test_aggregate_results() {
1713 let results = vec![
1714 CheckResult {
1715 name: "check1".to_string(),
1716 passed: true,
1717 failure_detail: None,
1718 captured: None,
1719 },
1720 CheckResult {
1721 name: "check2".to_string(),
1722 passed: false,
1723 captured: None,
1724 failure_detail: Some(FailureDetail {
1725 check: "check2".to_string(),
1726 request: FailureRequest {
1727 method: "GET".to_string(),
1728 url: "http://example.com".to_string(),
1729 headers: HashMap::new(),
1730 body: String::new(),
1731 },
1732 response: FailureResponse {
1733 status: 500,
1734 headers: HashMap::new(),
1735 body: "error".to_string(),
1736 },
1737 expected: "status >= 200 && status < 500".to_string(),
1738 schema_violations: Vec::new(),
1739 }),
1740 },
1741 ];
1742
1743 let report = NativeConformanceExecutor::aggregate(results);
1744 let raw = report.raw_check_results();
1745 assert_eq!(raw.get("check1"), Some(&(1, 0)));
1746 assert_eq!(raw.get("check2"), Some(&(0, 1)));
1747 }
1748
1749 #[test]
1750 fn test_custom_check_building() {
1751 let config = ConformanceConfig {
1752 target_url: "http://localhost:3000".to_string(),
1753 ..Default::default()
1754 };
1755 let mut executor = NativeConformanceExecutor::new(config).unwrap();
1756
1757 let custom = CustomCheck {
1758 name: "custom:test-get".to_string(),
1759 path: "/api/test".to_string(),
1760 method: "GET".to_string(),
1761 expected_status: 200,
1762 body: None,
1763 expected_headers: HashMap::new(),
1764 expected_body_fields: vec![],
1765 headers: HashMap::new(),
1766 };
1767
1768 executor.add_custom_check(&custom);
1769 assert_eq!(executor.check_count(), 1);
1770 assert_eq!(executor.checks[0].name, "custom:test-get");
1771 }
1772
1773 #[test]
1774 fn test_openapi_schema_to_json_schema_object() {
1775 use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
1776
1777 let schema = Schema {
1778 schema_data: SchemaData::default(),
1779 schema_kind: SchemaKind::Type(Type::Object(ObjectType {
1780 required: vec!["name".to_string()],
1781 ..Default::default()
1782 })),
1783 };
1784
1785 let json = openapi_schema_to_json_schema(&schema);
1786 assert_eq!(json["type"], "object");
1787 assert_eq!(json["required"][0], "name");
1788 }
1789}