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}
112
113pub struct NativeConformanceExecutor {
115 config: ConformanceConfig,
116 client: Client,
117 checks: Vec<ConformanceCheck>,
118}
119
120impl NativeConformanceExecutor {
121 pub fn new(config: ConformanceConfig) -> Result<Self> {
123 let mut builder = Client::builder()
124 .timeout(Duration::from_secs(30))
125 .connect_timeout(Duration::from_secs(10));
126
127 if config.skip_tls_verify {
128 builder = builder.danger_accept_invalid_certs(true);
129 }
130
131 let client = builder
132 .build()
133 .map_err(|e| BenchError::Other(format!("Failed to build HTTP client: {}", e)))?;
134
135 Ok(Self {
136 config,
137 client,
138 checks: Vec::new(),
139 })
140 }
141
142 #[must_use]
145 pub fn with_reference_checks(mut self) -> Self {
146 if self.config.should_include_category("Parameters") {
148 self.add_ref_get("param:path:string", "/conformance/params/hello");
149 self.add_ref_get("param:path:integer", "/conformance/params/42");
150 self.add_ref_get("param:query:string", "/conformance/params/query?name=test");
151 self.add_ref_get("param:query:integer", "/conformance/params/query?count=10");
152 self.add_ref_get("param:query:array", "/conformance/params/query?tags=a&tags=b");
153 self.checks.push(ConformanceCheck {
154 name: "param:header".to_string(),
155 method: Method::GET,
156 path: "/conformance/params/header".to_string(),
157 headers: self
158 .merge_headers(vec![("X-Custom-Param".to_string(), "test-value".to_string())]),
159 body: None,
160 validation: CheckValidation::StatusRange {
161 min: 200,
162 max_exclusive: 500,
163 },
164 });
165 self.checks.push(ConformanceCheck {
166 name: "param:cookie".to_string(),
167 method: Method::GET,
168 path: "/conformance/params/cookie".to_string(),
169 headers: self
170 .merge_headers(vec![("Cookie".to_string(), "session=abc123".to_string())]),
171 body: None,
172 validation: CheckValidation::StatusRange {
173 min: 200,
174 max_exclusive: 500,
175 },
176 });
177 }
178
179 if self.config.should_include_category("Request Bodies") {
181 self.checks.push(ConformanceCheck {
182 name: "body:json".to_string(),
183 method: Method::POST,
184 path: "/conformance/body/json".to_string(),
185 headers: self.merge_headers(vec![(
186 "Content-Type".to_string(),
187 "application/json".to_string(),
188 )]),
189 body: Some(CheckBody::Json(serde_json::json!({"name": "test", "value": 42}))),
190 validation: CheckValidation::StatusRange {
191 min: 200,
192 max_exclusive: 500,
193 },
194 });
195 self.checks.push(ConformanceCheck {
196 name: "body:form-urlencoded".to_string(),
197 method: Method::POST,
198 path: "/conformance/body/form".to_string(),
199 headers: self.custom_headers_only(),
200 body: Some(CheckBody::FormUrlencoded(vec![
201 ("field1".to_string(), "value1".to_string()),
202 ("field2".to_string(), "value2".to_string()),
203 ])),
204 validation: CheckValidation::StatusRange {
205 min: 200,
206 max_exclusive: 500,
207 },
208 });
209 self.checks.push(ConformanceCheck {
210 name: "body:multipart".to_string(),
211 method: Method::POST,
212 path: "/conformance/body/multipart".to_string(),
213 headers: self.custom_headers_only(),
214 body: Some(CheckBody::Raw {
215 content: "test content".to_string(),
216 content_type: "text/plain".to_string(),
217 }),
218 validation: CheckValidation::StatusRange {
219 min: 200,
220 max_exclusive: 500,
221 },
222 });
223 }
224
225 if self.config.should_include_category("Schema Types") {
227 let types = [
228 ("string", r#"{"value": "hello"}"#, "schema:string"),
229 ("integer", r#"{"value": 42}"#, "schema:integer"),
230 ("number", r#"{"value": 3.14}"#, "schema:number"),
231 ("boolean", r#"{"value": true}"#, "schema:boolean"),
232 ("array", r#"{"value": [1, 2, 3]}"#, "schema:array"),
233 ("object", r#"{"value": {"nested": "data"}}"#, "schema:object"),
234 ];
235 for (type_name, body_str, check_name) in types {
236 self.checks.push(ConformanceCheck {
237 name: check_name.to_string(),
238 method: Method::POST,
239 path: format!("/conformance/schema/{}", type_name),
240 headers: self.merge_headers(vec![(
241 "Content-Type".to_string(),
242 "application/json".to_string(),
243 )]),
244 body: Some(CheckBody::Json(
245 serde_json::from_str(body_str).expect("valid JSON"),
246 )),
247 validation: CheckValidation::StatusRange {
248 min: 200,
249 max_exclusive: 500,
250 },
251 });
252 }
253 }
254
255 if self.config.should_include_category("Composition") {
257 let compositions = [
258 ("oneOf", r#"{"type": "string", "value": "test"}"#, "composition:oneOf"),
259 ("anyOf", r#"{"value": "test"}"#, "composition:anyOf"),
260 ("allOf", r#"{"name": "test", "id": 1}"#, "composition:allOf"),
261 ];
262 for (kind, body_str, check_name) in compositions {
263 self.checks.push(ConformanceCheck {
264 name: check_name.to_string(),
265 method: Method::POST,
266 path: format!("/conformance/composition/{}", kind),
267 headers: self.merge_headers(vec![(
268 "Content-Type".to_string(),
269 "application/json".to_string(),
270 )]),
271 body: Some(CheckBody::Json(
272 serde_json::from_str(body_str).expect("valid JSON"),
273 )),
274 validation: CheckValidation::StatusRange {
275 min: 200,
276 max_exclusive: 500,
277 },
278 });
279 }
280 }
281
282 if self.config.should_include_category("String Formats") {
284 let formats = [
285 ("date", r#"{"value": "2024-01-15"}"#, "format:date"),
286 ("date-time", r#"{"value": "2024-01-15T10:30:00Z"}"#, "format:date-time"),
287 ("email", r#"{"value": "test@example.com"}"#, "format:email"),
288 ("uuid", r#"{"value": "550e8400-e29b-41d4-a716-446655440000"}"#, "format:uuid"),
289 ("uri", r#"{"value": "https://example.com/path"}"#, "format:uri"),
290 ("ipv4", r#"{"value": "192.168.1.1"}"#, "format:ipv4"),
291 ("ipv6", r#"{"value": "::1"}"#, "format:ipv6"),
292 ];
293 for (fmt, body_str, check_name) in formats {
294 self.checks.push(ConformanceCheck {
295 name: check_name.to_string(),
296 method: Method::POST,
297 path: format!("/conformance/formats/{}", fmt),
298 headers: self.merge_headers(vec![(
299 "Content-Type".to_string(),
300 "application/json".to_string(),
301 )]),
302 body: Some(CheckBody::Json(
303 serde_json::from_str(body_str).expect("valid JSON"),
304 )),
305 validation: CheckValidation::StatusRange {
306 min: 200,
307 max_exclusive: 500,
308 },
309 });
310 }
311 }
312
313 if self.config.should_include_category("Constraints") {
315 let constraints = [
316 ("required", r#"{"required_field": "present"}"#, "constraint:required"),
317 ("optional", r#"{}"#, "constraint:optional"),
318 ("minmax", r#"{"value": 50}"#, "constraint:minmax"),
319 ("pattern", r#"{"value": "ABC-123"}"#, "constraint:pattern"),
320 ("enum", r#"{"status": "active"}"#, "constraint:enum"),
321 ];
322 for (kind, body_str, check_name) in constraints {
323 self.checks.push(ConformanceCheck {
324 name: check_name.to_string(),
325 method: Method::POST,
326 path: format!("/conformance/constraints/{}", kind),
327 headers: self.merge_headers(vec![(
328 "Content-Type".to_string(),
329 "application/json".to_string(),
330 )]),
331 body: Some(CheckBody::Json(
332 serde_json::from_str(body_str).expect("valid JSON"),
333 )),
334 validation: CheckValidation::StatusRange {
335 min: 200,
336 max_exclusive: 500,
337 },
338 });
339 }
340 }
341
342 if self.config.should_include_category("Response Codes") {
344 for (code_str, check_name) in [
345 ("200", "response:200"),
346 ("201", "response:201"),
347 ("204", "response:204"),
348 ("400", "response:400"),
349 ("404", "response:404"),
350 ] {
351 let code: u16 = code_str.parse().unwrap();
352 self.checks.push(ConformanceCheck {
353 name: check_name.to_string(),
354 method: Method::GET,
355 path: format!("/conformance/responses/{}", code_str),
356 headers: self.custom_headers_only(),
357 body: None,
358 validation: CheckValidation::ExactStatus(code),
359 });
360 }
361 }
362
363 if self.config.should_include_category("HTTP Methods") {
365 self.add_ref_get("method:GET", "/conformance/methods");
366 for (method, check_name) in [
367 (Method::POST, "method:POST"),
368 (Method::PUT, "method:PUT"),
369 (Method::PATCH, "method:PATCH"),
370 ] {
371 self.checks.push(ConformanceCheck {
372 name: check_name.to_string(),
373 method,
374 path: "/conformance/methods".to_string(),
375 headers: self.merge_headers(vec![(
376 "Content-Type".to_string(),
377 "application/json".to_string(),
378 )]),
379 body: Some(CheckBody::Json(serde_json::json!({"action": "test"}))),
380 validation: CheckValidation::StatusRange {
381 min: 200,
382 max_exclusive: 500,
383 },
384 });
385 }
386 for (method, check_name) in [
387 (Method::DELETE, "method:DELETE"),
388 (Method::HEAD, "method:HEAD"),
389 (Method::OPTIONS, "method:OPTIONS"),
390 ] {
391 self.checks.push(ConformanceCheck {
392 name: check_name.to_string(),
393 method,
394 path: "/conformance/methods".to_string(),
395 headers: self.custom_headers_only(),
396 body: None,
397 validation: CheckValidation::StatusRange {
398 min: 200,
399 max_exclusive: 500,
400 },
401 });
402 }
403 }
404
405 if self.config.should_include_category("Content Types") {
407 self.checks.push(ConformanceCheck {
408 name: "content:negotiation".to_string(),
409 method: Method::GET,
410 path: "/conformance/content-types".to_string(),
411 headers: self
412 .merge_headers(vec![("Accept".to_string(), "application/json".to_string())]),
413 body: None,
414 validation: CheckValidation::StatusRange {
415 min: 200,
416 max_exclusive: 500,
417 },
418 });
419 }
420
421 if self.config.should_include_category("Security") {
423 self.checks.push(ConformanceCheck {
425 name: "security:bearer".to_string(),
426 method: Method::GET,
427 path: "/conformance/security/bearer".to_string(),
428 headers: self.merge_headers(vec![(
429 "Authorization".to_string(),
430 "Bearer test-token-123".to_string(),
431 )]),
432 body: None,
433 validation: CheckValidation::StatusRange {
434 min: 200,
435 max_exclusive: 500,
436 },
437 });
438
439 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
441 self.checks.push(ConformanceCheck {
442 name: "security:apikey".to_string(),
443 method: Method::GET,
444 path: "/conformance/security/apikey".to_string(),
445 headers: self.merge_headers(vec![("X-API-Key".to_string(), api_key.to_string())]),
446 body: None,
447 validation: CheckValidation::StatusRange {
448 min: 200,
449 max_exclusive: 500,
450 },
451 });
452
453 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
455 use base64::Engine;
456 let encoded = base64::engine::general_purpose::STANDARD.encode(basic_creds.as_bytes());
457 self.checks.push(ConformanceCheck {
458 name: "security:basic".to_string(),
459 method: Method::GET,
460 path: "/conformance/security/basic".to_string(),
461 headers: self.merge_headers(vec![(
462 "Authorization".to_string(),
463 format!("Basic {}", encoded),
464 )]),
465 body: None,
466 validation: CheckValidation::StatusRange {
467 min: 200,
468 max_exclusive: 500,
469 },
470 });
471 }
472
473 self
474 }
475
476 #[must_use]
478 pub fn with_spec_driven_checks(mut self, operations: &[AnnotatedOperation]) -> Self {
479 let mut feature_seen: HashSet<&'static str> = HashSet::new();
481
482 for op in operations {
483 for feature in &op.features {
484 let category = feature.category();
485 if !self.config.should_include_category(category) {
486 continue;
487 }
488
489 let check_name_base = feature.check_name();
490
491 if self.config.all_operations {
492 let check_name = format!("{}:{}", check_name_base, op.path);
494 let check = self.build_spec_check(&check_name, op, feature);
495 self.checks.push(check);
496 } else {
497 if feature_seen.insert(check_name_base) {
499 let check_name = format!("{}:{}", check_name_base, op.path);
500 let check = self.build_spec_check(&check_name, op, feature);
501 self.checks.push(check);
502 }
503 }
504 }
505 }
506
507 self
508 }
509
510 pub fn with_custom_checks(mut self) -> Result<Self> {
512 let path = match &self.config.custom_checks_file {
513 Some(p) => p.clone(),
514 None => return Ok(self),
515 };
516 let custom_config = CustomConformanceConfig::from_file(&path)?;
517 for check in &custom_config.custom_checks {
518 self.add_custom_check(check);
519 }
520 Ok(self)
521 }
522
523 pub fn check_count(&self) -> usize {
525 self.checks.len()
526 }
527
528 pub async fn execute(&self) -> Result<ConformanceReport> {
530 let mut results = Vec::with_capacity(self.checks.len());
531 let delay = self.config.request_delay_ms;
532
533 for (i, check) in self.checks.iter().enumerate() {
534 if delay > 0 && i > 0 {
535 tokio::time::sleep(Duration::from_millis(delay)).await;
536 }
537 let result = self.execute_check(check).await;
538 results.push(result);
539 }
540
541 Ok(Self::aggregate(results))
542 }
543
544 pub async fn execute_with_progress(
546 &self,
547 tx: mpsc::Sender<ConformanceProgress>,
548 ) -> Result<ConformanceReport> {
549 let total = self.checks.len();
550 let delay = self.config.request_delay_ms;
551 let _ = tx
552 .send(ConformanceProgress::Started {
553 total_checks: total,
554 })
555 .await;
556
557 let mut results = Vec::with_capacity(total);
558
559 for (i, check) in self.checks.iter().enumerate() {
560 if delay > 0 && i > 0 {
561 tokio::time::sleep(Duration::from_millis(delay)).await;
562 }
563 let result = self.execute_check(check).await;
564 let passed = result.passed;
565 let name = result.name.clone();
566 results.push(result);
567
568 let _ = tx
569 .send(ConformanceProgress::CheckCompleted {
570 name,
571 passed,
572 checks_done: i + 1,
573 })
574 .await;
575 }
576
577 let _ = tx.send(ConformanceProgress::Finished).await;
578 Ok(Self::aggregate(results))
579 }
580
581 async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
583 let base_url = self.config.effective_base_url();
584 let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
585
586 let mut request = self.client.request(check.method.clone(), &url);
587
588 for (name, value) in &check.headers {
590 request = request.header(name.as_str(), value.as_str());
591 }
592
593 match &check.body {
595 Some(CheckBody::Json(value)) => {
596 request = request.json(value);
597 }
598 Some(CheckBody::FormUrlencoded(fields)) => {
599 request = request.form(fields);
600 }
601 Some(CheckBody::Raw {
602 content,
603 content_type,
604 }) => {
605 if content_type == "text/plain" && check.path.contains("multipart") {
607 let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
608 .file_name("test.txt")
609 .mime_str(content_type)
610 .unwrap_or_else(|_| {
611 reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
612 });
613 let form = reqwest::multipart::Form::new().part("field", part);
614 request = request.multipart(form);
615 } else {
616 request =
617 request.header("Content-Type", content_type.as_str()).body(content.clone());
618 }
619 }
620 None => {}
621 }
622
623 let response = match request.send().await {
624 Ok(resp) => resp,
625 Err(e) => {
626 return CheckResult {
627 name: check.name.clone(),
628 passed: false,
629 failure_detail: Some(FailureDetail {
630 check: check.name.clone(),
631 request: FailureRequest {
632 method: check.method.to_string(),
633 url: url.clone(),
634 headers: HashMap::new(),
635 body: String::new(),
636 },
637 response: FailureResponse {
638 status: 0,
639 headers: HashMap::new(),
640 body: format!("Request failed: {}", e),
641 },
642 expected: format!("{:?}", check.validation),
643 schema_violations: Vec::new(),
644 }),
645 };
646 }
647 };
648
649 let status = response.status().as_u16();
650 let resp_headers: HashMap<String, String> = response
651 .headers()
652 .iter()
653 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
654 .collect();
655 let resp_body = response.text().await.unwrap_or_default();
656
657 let (passed, schema_violations) =
658 self.validate_response(&check.validation, status, &resp_headers, &resp_body);
659
660 let failure_detail = if !passed {
661 Some(FailureDetail {
662 check: check.name.clone(),
663 request: FailureRequest {
664 method: check.method.to_string(),
665 url,
666 headers: check.headers.iter().cloned().collect(),
667 body: match &check.body {
668 Some(CheckBody::Json(v)) => v.to_string(),
669 Some(CheckBody::FormUrlencoded(f)) => f
670 .iter()
671 .map(|(k, v)| format!("{}={}", k, v))
672 .collect::<Vec<_>>()
673 .join("&"),
674 Some(CheckBody::Raw { content, .. }) => content.clone(),
675 None => String::new(),
676 },
677 },
678 response: FailureResponse {
679 status,
680 headers: resp_headers,
681 body: if resp_body.len() > 500 {
682 format!("{}...", &resp_body[..500])
683 } else {
684 resp_body
685 },
686 },
687 expected: Self::describe_validation(&check.validation),
688 schema_violations,
689 })
690 } else {
691 None
692 };
693
694 CheckResult {
695 name: check.name.clone(),
696 passed,
697 failure_detail,
698 }
699 }
700
701 fn validate_response(
706 &self,
707 validation: &CheckValidation,
708 status: u16,
709 headers: &HashMap<String, String>,
710 body: &str,
711 ) -> (bool, Vec<SchemaViolation>) {
712 match validation {
713 CheckValidation::StatusRange { min, max_exclusive } => {
714 (status >= *min && status < *max_exclusive, Vec::new())
715 }
716 CheckValidation::ExactStatus(expected) => (status == *expected, Vec::new()),
717 CheckValidation::SchemaValidation {
718 status_min,
719 status_max,
720 schema,
721 } => {
722 if status < *status_min || status >= *status_max {
723 return (false, Vec::new());
724 }
725 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
727 return (
728 false,
729 vec![SchemaViolation {
730 field_path: "/".to_string(),
731 violation_type: "parse_error".to_string(),
732 expected: "valid JSON".to_string(),
733 actual: "non-JSON response body".to_string(),
734 }],
735 );
736 };
737 match jsonschema::validator_for(schema) {
738 Ok(validator) => {
739 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
740 if errors.is_empty() {
741 (true, Vec::new())
742 } else {
743 let violations = errors
744 .iter()
745 .map(|err| {
746 let field_path = err.instance_path.to_string();
747 let field_path = if field_path.is_empty() {
748 "/".to_string()
749 } else {
750 field_path
751 };
752 SchemaViolation {
753 field_path,
754 violation_type: format!("{:?}", err.kind)
755 .split('(')
756 .next()
757 .unwrap_or("unknown")
758 .split('{')
759 .next()
760 .unwrap_or("unknown")
761 .split(' ')
762 .next()
763 .unwrap_or("unknown")
764 .trim()
765 .to_string(),
766 expected: {
767 let schema_str = format!("{}", err.schema_path);
771 match &err.kind {
772 jsonschema::error::ValidationErrorKind::Type { kind } => {
773 format!("type: {:?}", kind)
774 }
775 jsonschema::error::ValidationErrorKind::Required { property } => {
776 format!("required field: {}", property)
777 }
778 _ => {
779 schema_str
781 .rsplit('/')
782 .next()
783 .unwrap_or(&schema_str)
784 .to_string()
785 }
786 }
787 },
788 actual: format!("{}", err),
789 }
790 })
791 .collect();
792 (false, violations)
793 }
794 }
795 Err(_) => {
796 (
798 false,
799 vec![SchemaViolation {
800 field_path: "/".to_string(),
801 violation_type: "schema_compile_error".to_string(),
802 expected: "valid JSON schema".to_string(),
803 actual: "schema failed to compile".to_string(),
804 }],
805 )
806 }
807 }
808 }
809 CheckValidation::Custom {
810 expected_status,
811 expected_headers,
812 expected_body_fields,
813 } => {
814 if status != *expected_status {
815 return (false, Vec::new());
816 }
817 for (header_name, pattern) in expected_headers {
819 let header_val = headers
820 .get(header_name)
821 .or_else(|| headers.get(&header_name.to_lowercase()))
822 .map(|s| s.as_str())
823 .unwrap_or("");
824 if let Ok(re) = regex::Regex::new(pattern) {
825 if !re.is_match(header_val) {
826 return (false, Vec::new());
827 }
828 }
829 }
830 if !expected_body_fields.is_empty() {
832 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
833 return (false, Vec::new());
834 };
835 for (field_name, field_type) in expected_body_fields {
836 let field = &body_value[field_name];
837 let ok = match field_type.as_str() {
838 "string" => field.is_string(),
839 "integer" => field.is_i64() || field.is_u64(),
840 "number" => field.is_number(),
841 "boolean" => field.is_boolean(),
842 "array" => field.is_array(),
843 "object" => field.is_object(),
844 _ => !field.is_null(),
845 };
846 if !ok {
847 return (false, Vec::new());
848 }
849 }
850 }
851 (true, Vec::new())
852 }
853 }
854 }
855
856 fn describe_validation(validation: &CheckValidation) -> String {
858 match validation {
859 CheckValidation::StatusRange { min, max_exclusive } => {
860 format!("status >= {} && status < {}", min, max_exclusive)
861 }
862 CheckValidation::ExactStatus(code) => format!("status === {}", code),
863 CheckValidation::SchemaValidation {
864 status_min,
865 status_max,
866 ..
867 } => {
868 format!("status >= {} && status < {} + schema validation", status_min, status_max)
869 }
870 CheckValidation::Custom {
871 expected_status, ..
872 } => {
873 format!("status === {}", expected_status)
874 }
875 }
876 }
877
878 fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
880 let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
881 let mut failure_details = Vec::new();
882
883 for result in results {
884 let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
885 if result.passed {
886 entry.0 += 1;
887 } else {
888 entry.1 += 1;
889 }
890 if let Some(detail) = result.failure_detail {
891 failure_details.push(detail);
892 }
893 }
894
895 ConformanceReport::from_results(check_results, failure_details)
896 }
897
898 fn build_spec_check(
902 &self,
903 check_name: &str,
904 op: &AnnotatedOperation,
905 feature: &ConformanceFeature,
906 ) -> ConformanceCheck {
907 let mut url_path = op.path.clone();
909 for (name, value) in &op.path_params {
910 url_path = url_path.replace(&format!("{{{}}}", name), value);
911 }
912 if !op.query_params.is_empty() {
914 let qs: Vec<String> =
915 op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
916 url_path = format!("{}?{}", url_path, qs.join("&"));
917 }
918
919 let mut effective_headers = self.effective_headers(&op.header_params);
921
922 if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
924 let code = match feature {
925 ConformanceFeature::Response400 => "400",
926 ConformanceFeature::Response404 => "404",
927 _ => unreachable!(),
928 };
929 effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
930 }
931
932 let needs_auth = matches!(
934 feature,
935 ConformanceFeature::SecurityBearer
936 | ConformanceFeature::SecurityBasic
937 | ConformanceFeature::SecurityApiKey
938 ) || !op.security_schemes.is_empty();
939
940 if needs_auth {
941 self.inject_security_headers(&op.security_schemes, &mut effective_headers);
942 }
943
944 let method = match op.method.as_str() {
946 "GET" => Method::GET,
947 "POST" => Method::POST,
948 "PUT" => Method::PUT,
949 "PATCH" => Method::PATCH,
950 "DELETE" => Method::DELETE,
951 "HEAD" => Method::HEAD,
952 "OPTIONS" => Method::OPTIONS,
953 _ => Method::GET,
954 };
955
956 let body = match method {
958 Method::POST | Method::PUT | Method::PATCH => {
959 if let Some(sample) = &op.sample_body {
960 let content_type =
962 op.request_body_content_type.as_deref().unwrap_or("application/json");
963 if !effective_headers
964 .iter()
965 .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
966 {
967 effective_headers
968 .push(("Content-Type".to_string(), content_type.to_string()));
969 }
970 match content_type {
971 "application/x-www-form-urlencoded" => {
972 let fields: Vec<(String, String)> = serde_json::from_str::<
974 serde_json::Value,
975 >(
976 sample
977 )
978 .ok()
979 .and_then(|v| {
980 v.as_object().map(|obj| {
981 obj.iter()
982 .map(|(k, v)| {
983 (k.clone(), v.as_str().unwrap_or("").to_string())
984 })
985 .collect()
986 })
987 })
988 .unwrap_or_default();
989 Some(CheckBody::FormUrlencoded(fields))
990 }
991 _ => {
992 match serde_json::from_str::<serde_json::Value>(sample) {
994 Ok(v) => Some(CheckBody::Json(v)),
995 Err(_) => Some(CheckBody::Raw {
996 content: sample.clone(),
997 content_type: content_type.to_string(),
998 }),
999 }
1000 }
1001 }
1002 } else {
1003 None
1004 }
1005 }
1006 _ => None,
1007 };
1008
1009 let validation = self.determine_validation(feature, op);
1011
1012 ConformanceCheck {
1013 name: check_name.to_string(),
1014 method,
1015 path: url_path,
1016 headers: effective_headers,
1017 body,
1018 validation,
1019 }
1020 }
1021
1022 fn determine_validation(
1024 &self,
1025 feature: &ConformanceFeature,
1026 op: &AnnotatedOperation,
1027 ) -> CheckValidation {
1028 match feature {
1029 ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1030 ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1031 ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1032 ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1033 ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1034 ConformanceFeature::SecurityBearer
1035 | ConformanceFeature::SecurityBasic
1036 | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1037 min: 200,
1038 max_exclusive: 400,
1039 },
1040 ConformanceFeature::ResponseValidation => {
1041 if let Some(schema) = &op.response_schema {
1042 let schema_json = openapi_schema_to_json_schema(schema);
1044 CheckValidation::SchemaValidation {
1045 status_min: 200,
1046 status_max: 500,
1047 schema: schema_json,
1048 }
1049 } else {
1050 CheckValidation::StatusRange {
1051 min: 200,
1052 max_exclusive: 500,
1053 }
1054 }
1055 }
1056 _ => CheckValidation::StatusRange {
1057 min: 200,
1058 max_exclusive: 500,
1059 },
1060 }
1061 }
1062
1063 fn add_ref_get(&mut self, name: &str, path: &str) {
1065 self.checks.push(ConformanceCheck {
1066 name: name.to_string(),
1067 method: Method::GET,
1068 path: path.to_string(),
1069 headers: self.custom_headers_only(),
1070 body: None,
1071 validation: CheckValidation::StatusRange {
1072 min: 200,
1073 max_exclusive: 500,
1074 },
1075 });
1076 }
1077
1078 fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1080 let mut headers = Vec::new();
1081 for (k, v) in spec_headers {
1082 if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1084 continue;
1085 }
1086 headers.push((k.clone(), v.clone()));
1087 }
1088 headers.extend(self.config.custom_headers.clone());
1090 headers
1091 }
1092
1093 fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1095 for (k, v) in &self.config.custom_headers {
1096 if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1097 headers.push((k.clone(), v.clone()));
1098 }
1099 }
1100 headers
1101 }
1102
1103 fn custom_headers_only(&self) -> Vec<(String, String)> {
1105 self.config.custom_headers.clone()
1106 }
1107
1108 fn inject_security_headers(
1112 &self,
1113 schemes: &[SecuritySchemeInfo],
1114 headers: &mut Vec<(String, String)>,
1115 ) {
1116 let has_cookie_auth =
1118 self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1119 let mut to_add: Vec<(String, String)> = Vec::new();
1120
1121 for scheme in schemes {
1122 match scheme {
1123 SecuritySchemeInfo::Bearer => {
1124 if !has_cookie_auth
1125 && !Self::header_present(
1126 "Authorization",
1127 headers,
1128 &self.config.custom_headers,
1129 )
1130 {
1131 to_add.push((
1132 "Authorization".to_string(),
1133 "Bearer mockforge-conformance-test-token".to_string(),
1134 ));
1135 }
1136 }
1137 SecuritySchemeInfo::Basic => {
1138 if !has_cookie_auth
1139 && !Self::header_present(
1140 "Authorization",
1141 headers,
1142 &self.config.custom_headers,
1143 )
1144 {
1145 let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1146 use base64::Engine;
1147 let encoded =
1148 base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1149 to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1150 }
1151 }
1152 SecuritySchemeInfo::ApiKey { location, name } => match location {
1153 ApiKeyLocation::Header => {
1154 if !Self::header_present(name, headers, &self.config.custom_headers) {
1155 let key = self
1156 .config
1157 .api_key
1158 .as_deref()
1159 .unwrap_or("mockforge-conformance-test-key");
1160 to_add.push((name.clone(), key.to_string()));
1161 }
1162 }
1163 ApiKeyLocation::Cookie => {
1164 if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1165 to_add.push((
1166 "Cookie".to_string(),
1167 format!("{}=mockforge-conformance-test-session", name),
1168 ));
1169 }
1170 }
1171 ApiKeyLocation::Query => {
1172 }
1174 },
1175 }
1176 }
1177
1178 headers.extend(to_add);
1179 }
1180
1181 fn header_present(
1183 name: &str,
1184 headers: &[(String, String)],
1185 custom_headers: &[(String, String)],
1186 ) -> bool {
1187 headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1188 || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1189 }
1190
1191 fn add_custom_check(&mut self, check: &CustomCheck) {
1193 let method = match check.method.to_uppercase().as_str() {
1194 "GET" => Method::GET,
1195 "POST" => Method::POST,
1196 "PUT" => Method::PUT,
1197 "PATCH" => Method::PATCH,
1198 "DELETE" => Method::DELETE,
1199 "HEAD" => Method::HEAD,
1200 "OPTIONS" => Method::OPTIONS,
1201 _ => Method::GET,
1202 };
1203
1204 let mut headers: Vec<(String, String)> =
1206 check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1207 for (k, v) in &self.config.custom_headers {
1209 if !check.headers.contains_key(k) {
1210 headers.push((k.clone(), v.clone()));
1211 }
1212 }
1213 if check.body.is_some()
1215 && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1216 {
1217 headers.push(("Content-Type".to_string(), "application/json".to_string()));
1218 }
1219
1220 let body = check
1222 .body
1223 .as_ref()
1224 .and_then(|b| serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json));
1225
1226 let expected_headers: Vec<(String, String)> =
1228 check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1229
1230 let expected_body_fields: Vec<(String, String)> = check
1232 .expected_body_fields
1233 .iter()
1234 .map(|f| (f.name.clone(), f.field_type.clone()))
1235 .collect();
1236
1237 self.checks.push(ConformanceCheck {
1239 name: check.name.clone(),
1240 method,
1241 path: check.path.clone(),
1242 headers,
1243 body,
1244 validation: CheckValidation::Custom {
1245 expected_status: check.expected_status,
1246 expected_headers,
1247 expected_body_fields,
1248 },
1249 });
1250 }
1251}
1252
1253fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1256 use openapiv3::{SchemaKind, Type};
1257
1258 match &schema.schema_kind {
1259 SchemaKind::Type(Type::Object(obj)) => {
1260 let mut props = serde_json::Map::new();
1261 for (name, prop_ref) in &obj.properties {
1262 if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1263 props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1264 }
1265 }
1266 let mut schema_obj = serde_json::json!({
1267 "type": "object",
1268 "properties": props,
1269 });
1270 if !obj.required.is_empty() {
1271 schema_obj["required"] = serde_json::Value::Array(
1272 obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1273 );
1274 }
1275 schema_obj
1276 }
1277 SchemaKind::Type(Type::Array(arr)) => {
1278 let mut schema_obj = serde_json::json!({"type": "array"});
1279 if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1280 schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1281 }
1282 schema_obj
1283 }
1284 SchemaKind::Type(Type::String(s)) => {
1285 let mut obj = serde_json::json!({"type": "string"});
1286 if let Some(min) = s.min_length {
1287 obj["minLength"] = serde_json::json!(min);
1288 }
1289 if let Some(max) = s.max_length {
1290 obj["maxLength"] = serde_json::json!(max);
1291 }
1292 if let Some(pattern) = &s.pattern {
1293 obj["pattern"] = serde_json::json!(pattern);
1294 }
1295 if !s.enumeration.is_empty() {
1296 obj["enum"] = serde_json::Value::Array(
1297 s.enumeration
1298 .iter()
1299 .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1300 .collect(),
1301 );
1302 }
1303 obj
1304 }
1305 SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1306 SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1307 SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1308 _ => serde_json::json!({}),
1309 }
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314 use super::*;
1315
1316 #[test]
1317 fn test_reference_check_count() {
1318 let config = ConformanceConfig {
1319 target_url: "http://localhost:3000".to_string(),
1320 ..Default::default()
1321 };
1322 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1323 assert_eq!(executor.check_count(), 47);
1326 }
1327
1328 #[test]
1329 fn test_reference_checks_with_category_filter() {
1330 let config = ConformanceConfig {
1331 target_url: "http://localhost:3000".to_string(),
1332 categories: Some(vec!["Parameters".to_string()]),
1333 ..Default::default()
1334 };
1335 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1336 assert_eq!(executor.check_count(), 7);
1337 }
1338
1339 #[test]
1340 fn test_validate_status_range() {
1341 let config = ConformanceConfig {
1342 target_url: "http://localhost:3000".to_string(),
1343 ..Default::default()
1344 };
1345 let executor = NativeConformanceExecutor::new(config).unwrap();
1346 let headers = HashMap::new();
1347
1348 assert!(
1349 executor
1350 .validate_response(
1351 &CheckValidation::StatusRange {
1352 min: 200,
1353 max_exclusive: 500,
1354 },
1355 200,
1356 &headers,
1357 "",
1358 )
1359 .0
1360 );
1361 assert!(
1362 executor
1363 .validate_response(
1364 &CheckValidation::StatusRange {
1365 min: 200,
1366 max_exclusive: 500,
1367 },
1368 404,
1369 &headers,
1370 "",
1371 )
1372 .0
1373 );
1374 assert!(
1375 !executor
1376 .validate_response(
1377 &CheckValidation::StatusRange {
1378 min: 200,
1379 max_exclusive: 500,
1380 },
1381 500,
1382 &headers,
1383 "",
1384 )
1385 .0
1386 );
1387 }
1388
1389 #[test]
1390 fn test_validate_exact_status() {
1391 let config = ConformanceConfig {
1392 target_url: "http://localhost:3000".to_string(),
1393 ..Default::default()
1394 };
1395 let executor = NativeConformanceExecutor::new(config).unwrap();
1396 let headers = HashMap::new();
1397
1398 assert!(
1399 executor
1400 .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
1401 .0
1402 );
1403 assert!(
1404 !executor
1405 .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
1406 .0
1407 );
1408 }
1409
1410 #[test]
1411 fn test_validate_schema() {
1412 let config = ConformanceConfig {
1413 target_url: "http://localhost:3000".to_string(),
1414 ..Default::default()
1415 };
1416 let executor = NativeConformanceExecutor::new(config).unwrap();
1417 let headers = HashMap::new();
1418
1419 let schema = serde_json::json!({
1420 "type": "object",
1421 "properties": {
1422 "name": {"type": "string"},
1423 "age": {"type": "integer"}
1424 },
1425 "required": ["name"]
1426 });
1427
1428 let (passed, violations) = executor.validate_response(
1429 &CheckValidation::SchemaValidation {
1430 status_min: 200,
1431 status_max: 300,
1432 schema: schema.clone(),
1433 },
1434 200,
1435 &headers,
1436 r#"{"name": "test", "age": 25}"#,
1437 );
1438 assert!(passed);
1439 assert!(violations.is_empty());
1440
1441 let (passed, violations) = executor.validate_response(
1443 &CheckValidation::SchemaValidation {
1444 status_min: 200,
1445 status_max: 300,
1446 schema: schema.clone(),
1447 },
1448 200,
1449 &headers,
1450 r#"{"age": 25}"#,
1451 );
1452 assert!(!passed);
1453 assert!(!violations.is_empty());
1454 assert_eq!(violations[0].violation_type, "Required");
1455 }
1456
1457 #[test]
1458 fn test_validate_custom() {
1459 let config = ConformanceConfig {
1460 target_url: "http://localhost:3000".to_string(),
1461 ..Default::default()
1462 };
1463 let executor = NativeConformanceExecutor::new(config).unwrap();
1464 let mut headers = HashMap::new();
1465 headers.insert("content-type".to_string(), "application/json".to_string());
1466
1467 assert!(
1468 executor
1469 .validate_response(
1470 &CheckValidation::Custom {
1471 expected_status: 200,
1472 expected_headers: vec![(
1473 "content-type".to_string(),
1474 "application/json".to_string(),
1475 )],
1476 expected_body_fields: vec![("name".to_string(), "string".to_string())],
1477 },
1478 200,
1479 &headers,
1480 r#"{"name": "test"}"#,
1481 )
1482 .0
1483 );
1484
1485 assert!(
1487 !executor
1488 .validate_response(
1489 &CheckValidation::Custom {
1490 expected_status: 200,
1491 expected_headers: vec![],
1492 expected_body_fields: vec![],
1493 },
1494 404,
1495 &headers,
1496 "",
1497 )
1498 .0
1499 );
1500 }
1501
1502 #[test]
1503 fn test_aggregate_results() {
1504 let results = vec![
1505 CheckResult {
1506 name: "check1".to_string(),
1507 passed: true,
1508 failure_detail: None,
1509 },
1510 CheckResult {
1511 name: "check2".to_string(),
1512 passed: false,
1513 failure_detail: Some(FailureDetail {
1514 check: "check2".to_string(),
1515 request: FailureRequest {
1516 method: "GET".to_string(),
1517 url: "http://example.com".to_string(),
1518 headers: HashMap::new(),
1519 body: String::new(),
1520 },
1521 response: FailureResponse {
1522 status: 500,
1523 headers: HashMap::new(),
1524 body: "error".to_string(),
1525 },
1526 expected: "status >= 200 && status < 500".to_string(),
1527 schema_violations: Vec::new(),
1528 }),
1529 },
1530 ];
1531
1532 let report = NativeConformanceExecutor::aggregate(results);
1533 let raw = report.raw_check_results();
1534 assert_eq!(raw.get("check1"), Some(&(1, 0)));
1535 assert_eq!(raw.get("check2"), Some(&(0, 1)));
1536 }
1537
1538 #[test]
1539 fn test_custom_check_building() {
1540 let config = ConformanceConfig {
1541 target_url: "http://localhost:3000".to_string(),
1542 ..Default::default()
1543 };
1544 let mut executor = NativeConformanceExecutor::new(config).unwrap();
1545
1546 let custom = CustomCheck {
1547 name: "custom:test-get".to_string(),
1548 path: "/api/test".to_string(),
1549 method: "GET".to_string(),
1550 expected_status: 200,
1551 body: None,
1552 expected_headers: std::collections::HashMap::new(),
1553 expected_body_fields: vec![],
1554 headers: std::collections::HashMap::new(),
1555 };
1556
1557 executor.add_custom_check(&custom);
1558 assert_eq!(executor.check_count(), 1);
1559 assert_eq!(executor.checks[0].name, "custom:test-get");
1560 }
1561
1562 #[test]
1563 fn test_openapi_schema_to_json_schema_object() {
1564 use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
1565
1566 let schema = Schema {
1567 schema_data: SchemaData::default(),
1568 schema_kind: SchemaKind::Type(Type::Object(ObjectType {
1569 required: vec!["name".to_string()],
1570 ..Default::default()
1571 })),
1572 };
1573
1574 let json = openapi_schema_to_json_schema(&schema);
1575 assert_eq!(json["type"], "object");
1576 assert_eq!(json["required"][0], "name");
1577 }
1578}