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: format!("{}", err.schema_path),
767 actual: format!("{}", err),
768 }
769 })
770 .collect();
771 (false, violations)
772 }
773 }
774 Err(_) => {
775 (
777 false,
778 vec![SchemaViolation {
779 field_path: "/".to_string(),
780 violation_type: "schema_compile_error".to_string(),
781 expected: "valid JSON schema".to_string(),
782 actual: "schema failed to compile".to_string(),
783 }],
784 )
785 }
786 }
787 }
788 CheckValidation::Custom {
789 expected_status,
790 expected_headers,
791 expected_body_fields,
792 } => {
793 if status != *expected_status {
794 return (false, Vec::new());
795 }
796 for (header_name, pattern) in expected_headers {
798 let header_val = headers
799 .get(header_name)
800 .or_else(|| headers.get(&header_name.to_lowercase()))
801 .map(|s| s.as_str())
802 .unwrap_or("");
803 if let Ok(re) = regex::Regex::new(pattern) {
804 if !re.is_match(header_val) {
805 return (false, Vec::new());
806 }
807 }
808 }
809 if !expected_body_fields.is_empty() {
811 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
812 return (false, Vec::new());
813 };
814 for (field_name, field_type) in expected_body_fields {
815 let field = &body_value[field_name];
816 let ok = match field_type.as_str() {
817 "string" => field.is_string(),
818 "integer" => field.is_i64() || field.is_u64(),
819 "number" => field.is_number(),
820 "boolean" => field.is_boolean(),
821 "array" => field.is_array(),
822 "object" => field.is_object(),
823 _ => !field.is_null(),
824 };
825 if !ok {
826 return (false, Vec::new());
827 }
828 }
829 }
830 (true, Vec::new())
831 }
832 }
833 }
834
835 fn describe_validation(validation: &CheckValidation) -> String {
837 match validation {
838 CheckValidation::StatusRange { min, max_exclusive } => {
839 format!("status >= {} && status < {}", min, max_exclusive)
840 }
841 CheckValidation::ExactStatus(code) => format!("status === {}", code),
842 CheckValidation::SchemaValidation {
843 status_min,
844 status_max,
845 ..
846 } => {
847 format!("status >= {} && status < {} + schema validation", status_min, status_max)
848 }
849 CheckValidation::Custom {
850 expected_status, ..
851 } => {
852 format!("status === {}", expected_status)
853 }
854 }
855 }
856
857 fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
859 let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
860 let mut failure_details = Vec::new();
861
862 for result in results {
863 let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
864 if result.passed {
865 entry.0 += 1;
866 } else {
867 entry.1 += 1;
868 }
869 if let Some(detail) = result.failure_detail {
870 failure_details.push(detail);
871 }
872 }
873
874 ConformanceReport::from_results(check_results, failure_details)
875 }
876
877 fn build_spec_check(
881 &self,
882 check_name: &str,
883 op: &AnnotatedOperation,
884 feature: &ConformanceFeature,
885 ) -> ConformanceCheck {
886 let mut url_path = op.path.clone();
888 for (name, value) in &op.path_params {
889 url_path = url_path.replace(&format!("{{{}}}", name), value);
890 }
891 if !op.query_params.is_empty() {
893 let qs: Vec<String> =
894 op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
895 url_path = format!("{}?{}", url_path, qs.join("&"));
896 }
897
898 let mut effective_headers = self.effective_headers(&op.header_params);
900
901 if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
903 let code = match feature {
904 ConformanceFeature::Response400 => "400",
905 ConformanceFeature::Response404 => "404",
906 _ => unreachable!(),
907 };
908 effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
909 }
910
911 let needs_auth = matches!(
913 feature,
914 ConformanceFeature::SecurityBearer
915 | ConformanceFeature::SecurityBasic
916 | ConformanceFeature::SecurityApiKey
917 ) || !op.security_schemes.is_empty();
918
919 if needs_auth {
920 self.inject_security_headers(&op.security_schemes, &mut effective_headers);
921 }
922
923 let method = match op.method.as_str() {
925 "GET" => Method::GET,
926 "POST" => Method::POST,
927 "PUT" => Method::PUT,
928 "PATCH" => Method::PATCH,
929 "DELETE" => Method::DELETE,
930 "HEAD" => Method::HEAD,
931 "OPTIONS" => Method::OPTIONS,
932 _ => Method::GET,
933 };
934
935 let body = match method {
937 Method::POST | Method::PUT | Method::PATCH => {
938 if let Some(sample) = &op.sample_body {
939 let content_type =
941 op.request_body_content_type.as_deref().unwrap_or("application/json");
942 if !effective_headers
943 .iter()
944 .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
945 {
946 effective_headers
947 .push(("Content-Type".to_string(), content_type.to_string()));
948 }
949 match content_type {
950 "application/x-www-form-urlencoded" => {
951 let fields: Vec<(String, String)> = serde_json::from_str::<
953 serde_json::Value,
954 >(
955 sample
956 )
957 .ok()
958 .and_then(|v| {
959 v.as_object().map(|obj| {
960 obj.iter()
961 .map(|(k, v)| {
962 (k.clone(), v.as_str().unwrap_or("").to_string())
963 })
964 .collect()
965 })
966 })
967 .unwrap_or_default();
968 Some(CheckBody::FormUrlencoded(fields))
969 }
970 _ => {
971 match serde_json::from_str::<serde_json::Value>(sample) {
973 Ok(v) => Some(CheckBody::Json(v)),
974 Err(_) => Some(CheckBody::Raw {
975 content: sample.clone(),
976 content_type: content_type.to_string(),
977 }),
978 }
979 }
980 }
981 } else {
982 None
983 }
984 }
985 _ => None,
986 };
987
988 let validation = self.determine_validation(feature, op);
990
991 ConformanceCheck {
992 name: check_name.to_string(),
993 method,
994 path: url_path,
995 headers: effective_headers,
996 body,
997 validation,
998 }
999 }
1000
1001 fn determine_validation(
1003 &self,
1004 feature: &ConformanceFeature,
1005 op: &AnnotatedOperation,
1006 ) -> CheckValidation {
1007 match feature {
1008 ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1009 ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1010 ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1011 ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1012 ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1013 ConformanceFeature::SecurityBearer
1014 | ConformanceFeature::SecurityBasic
1015 | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1016 min: 200,
1017 max_exclusive: 400,
1018 },
1019 ConformanceFeature::ResponseValidation => {
1020 if let Some(schema) = &op.response_schema {
1021 let schema_json = openapi_schema_to_json_schema(schema);
1023 CheckValidation::SchemaValidation {
1024 status_min: 200,
1025 status_max: 500,
1026 schema: schema_json,
1027 }
1028 } else {
1029 CheckValidation::StatusRange {
1030 min: 200,
1031 max_exclusive: 500,
1032 }
1033 }
1034 }
1035 _ => CheckValidation::StatusRange {
1036 min: 200,
1037 max_exclusive: 500,
1038 },
1039 }
1040 }
1041
1042 fn add_ref_get(&mut self, name: &str, path: &str) {
1044 self.checks.push(ConformanceCheck {
1045 name: name.to_string(),
1046 method: Method::GET,
1047 path: path.to_string(),
1048 headers: self.custom_headers_only(),
1049 body: None,
1050 validation: CheckValidation::StatusRange {
1051 min: 200,
1052 max_exclusive: 500,
1053 },
1054 });
1055 }
1056
1057 fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1059 let mut headers = Vec::new();
1060 for (k, v) in spec_headers {
1061 if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1063 continue;
1064 }
1065 headers.push((k.clone(), v.clone()));
1066 }
1067 headers.extend(self.config.custom_headers.clone());
1069 headers
1070 }
1071
1072 fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1074 for (k, v) in &self.config.custom_headers {
1075 if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1076 headers.push((k.clone(), v.clone()));
1077 }
1078 }
1079 headers
1080 }
1081
1082 fn custom_headers_only(&self) -> Vec<(String, String)> {
1084 self.config.custom_headers.clone()
1085 }
1086
1087 fn inject_security_headers(
1091 &self,
1092 schemes: &[SecuritySchemeInfo],
1093 headers: &mut Vec<(String, String)>,
1094 ) {
1095 let has_cookie_auth =
1097 self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1098 let mut to_add: Vec<(String, String)> = Vec::new();
1099
1100 for scheme in schemes {
1101 match scheme {
1102 SecuritySchemeInfo::Bearer => {
1103 if !has_cookie_auth
1104 && !Self::header_present(
1105 "Authorization",
1106 headers,
1107 &self.config.custom_headers,
1108 )
1109 {
1110 to_add.push((
1111 "Authorization".to_string(),
1112 "Bearer mockforge-conformance-test-token".to_string(),
1113 ));
1114 }
1115 }
1116 SecuritySchemeInfo::Basic => {
1117 if !has_cookie_auth
1118 && !Self::header_present(
1119 "Authorization",
1120 headers,
1121 &self.config.custom_headers,
1122 )
1123 {
1124 let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1125 use base64::Engine;
1126 let encoded =
1127 base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1128 to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1129 }
1130 }
1131 SecuritySchemeInfo::ApiKey { location, name } => match location {
1132 ApiKeyLocation::Header => {
1133 if !Self::header_present(name, headers, &self.config.custom_headers) {
1134 let key = self
1135 .config
1136 .api_key
1137 .as_deref()
1138 .unwrap_or("mockforge-conformance-test-key");
1139 to_add.push((name.clone(), key.to_string()));
1140 }
1141 }
1142 ApiKeyLocation::Cookie => {
1143 if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1144 to_add.push((
1145 "Cookie".to_string(),
1146 format!("{}=mockforge-conformance-test-session", name),
1147 ));
1148 }
1149 }
1150 ApiKeyLocation::Query => {
1151 }
1153 },
1154 }
1155 }
1156
1157 headers.extend(to_add);
1158 }
1159
1160 fn header_present(
1162 name: &str,
1163 headers: &[(String, String)],
1164 custom_headers: &[(String, String)],
1165 ) -> bool {
1166 headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1167 || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1168 }
1169
1170 fn add_custom_check(&mut self, check: &CustomCheck) {
1172 let method = match check.method.to_uppercase().as_str() {
1173 "GET" => Method::GET,
1174 "POST" => Method::POST,
1175 "PUT" => Method::PUT,
1176 "PATCH" => Method::PATCH,
1177 "DELETE" => Method::DELETE,
1178 "HEAD" => Method::HEAD,
1179 "OPTIONS" => Method::OPTIONS,
1180 _ => Method::GET,
1181 };
1182
1183 let mut headers: Vec<(String, String)> =
1185 check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1186 for (k, v) in &self.config.custom_headers {
1188 if !check.headers.contains_key(k) {
1189 headers.push((k.clone(), v.clone()));
1190 }
1191 }
1192 if check.body.is_some()
1194 && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1195 {
1196 headers.push(("Content-Type".to_string(), "application/json".to_string()));
1197 }
1198
1199 let body = check
1201 .body
1202 .as_ref()
1203 .and_then(|b| serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json));
1204
1205 let expected_headers: Vec<(String, String)> =
1207 check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1208
1209 let expected_body_fields: Vec<(String, String)> = check
1211 .expected_body_fields
1212 .iter()
1213 .map(|f| (f.name.clone(), f.field_type.clone()))
1214 .collect();
1215
1216 self.checks.push(ConformanceCheck {
1218 name: check.name.clone(),
1219 method,
1220 path: check.path.clone(),
1221 headers,
1222 body,
1223 validation: CheckValidation::Custom {
1224 expected_status: check.expected_status,
1225 expected_headers,
1226 expected_body_fields,
1227 },
1228 });
1229 }
1230}
1231
1232fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1235 use openapiv3::{SchemaKind, Type};
1236
1237 match &schema.schema_kind {
1238 SchemaKind::Type(Type::Object(obj)) => {
1239 let mut props = serde_json::Map::new();
1240 for (name, prop_ref) in &obj.properties {
1241 if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1242 props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1243 }
1244 }
1245 let mut schema_obj = serde_json::json!({
1246 "type": "object",
1247 "properties": props,
1248 });
1249 if !obj.required.is_empty() {
1250 schema_obj["required"] = serde_json::Value::Array(
1251 obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1252 );
1253 }
1254 schema_obj
1255 }
1256 SchemaKind::Type(Type::Array(arr)) => {
1257 let mut schema_obj = serde_json::json!({"type": "array"});
1258 if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1259 schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1260 }
1261 schema_obj
1262 }
1263 SchemaKind::Type(Type::String(s)) => {
1264 let mut obj = serde_json::json!({"type": "string"});
1265 if let Some(min) = s.min_length {
1266 obj["minLength"] = serde_json::json!(min);
1267 }
1268 if let Some(max) = s.max_length {
1269 obj["maxLength"] = serde_json::json!(max);
1270 }
1271 if let Some(pattern) = &s.pattern {
1272 obj["pattern"] = serde_json::json!(pattern);
1273 }
1274 if !s.enumeration.is_empty() {
1275 obj["enum"] = serde_json::Value::Array(
1276 s.enumeration
1277 .iter()
1278 .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1279 .collect(),
1280 );
1281 }
1282 obj
1283 }
1284 SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1285 SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1286 SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1287 _ => serde_json::json!({}),
1288 }
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294
1295 #[test]
1296 fn test_reference_check_count() {
1297 let config = ConformanceConfig {
1298 target_url: "http://localhost:3000".to_string(),
1299 ..Default::default()
1300 };
1301 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1302 assert_eq!(executor.check_count(), 47);
1305 }
1306
1307 #[test]
1308 fn test_reference_checks_with_category_filter() {
1309 let config = ConformanceConfig {
1310 target_url: "http://localhost:3000".to_string(),
1311 categories: Some(vec!["Parameters".to_string()]),
1312 ..Default::default()
1313 };
1314 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1315 assert_eq!(executor.check_count(), 7);
1316 }
1317
1318 #[test]
1319 fn test_validate_status_range() {
1320 let config = ConformanceConfig {
1321 target_url: "http://localhost:3000".to_string(),
1322 ..Default::default()
1323 };
1324 let executor = NativeConformanceExecutor::new(config).unwrap();
1325 let headers = HashMap::new();
1326
1327 assert!(
1328 executor
1329 .validate_response(
1330 &CheckValidation::StatusRange {
1331 min: 200,
1332 max_exclusive: 500,
1333 },
1334 200,
1335 &headers,
1336 "",
1337 )
1338 .0
1339 );
1340 assert!(
1341 executor
1342 .validate_response(
1343 &CheckValidation::StatusRange {
1344 min: 200,
1345 max_exclusive: 500,
1346 },
1347 404,
1348 &headers,
1349 "",
1350 )
1351 .0
1352 );
1353 assert!(
1354 !executor
1355 .validate_response(
1356 &CheckValidation::StatusRange {
1357 min: 200,
1358 max_exclusive: 500,
1359 },
1360 500,
1361 &headers,
1362 "",
1363 )
1364 .0
1365 );
1366 }
1367
1368 #[test]
1369 fn test_validate_exact_status() {
1370 let config = ConformanceConfig {
1371 target_url: "http://localhost:3000".to_string(),
1372 ..Default::default()
1373 };
1374 let executor = NativeConformanceExecutor::new(config).unwrap();
1375 let headers = HashMap::new();
1376
1377 assert!(
1378 executor
1379 .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
1380 .0
1381 );
1382 assert!(
1383 !executor
1384 .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
1385 .0
1386 );
1387 }
1388
1389 #[test]
1390 fn test_validate_schema() {
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 let schema = serde_json::json!({
1399 "type": "object",
1400 "properties": {
1401 "name": {"type": "string"},
1402 "age": {"type": "integer"}
1403 },
1404 "required": ["name"]
1405 });
1406
1407 let (passed, violations) = executor.validate_response(
1408 &CheckValidation::SchemaValidation {
1409 status_min: 200,
1410 status_max: 300,
1411 schema: schema.clone(),
1412 },
1413 200,
1414 &headers,
1415 r#"{"name": "test", "age": 25}"#,
1416 );
1417 assert!(passed);
1418 assert!(violations.is_empty());
1419
1420 let (passed, violations) = executor.validate_response(
1422 &CheckValidation::SchemaValidation {
1423 status_min: 200,
1424 status_max: 300,
1425 schema: schema.clone(),
1426 },
1427 200,
1428 &headers,
1429 r#"{"age": 25}"#,
1430 );
1431 assert!(!passed);
1432 assert!(!violations.is_empty());
1433 assert_eq!(violations[0].violation_type, "Required");
1434 }
1435
1436 #[test]
1437 fn test_validate_custom() {
1438 let config = ConformanceConfig {
1439 target_url: "http://localhost:3000".to_string(),
1440 ..Default::default()
1441 };
1442 let executor = NativeConformanceExecutor::new(config).unwrap();
1443 let mut headers = HashMap::new();
1444 headers.insert("content-type".to_string(), "application/json".to_string());
1445
1446 assert!(
1447 executor
1448 .validate_response(
1449 &CheckValidation::Custom {
1450 expected_status: 200,
1451 expected_headers: vec![(
1452 "content-type".to_string(),
1453 "application/json".to_string(),
1454 )],
1455 expected_body_fields: vec![("name".to_string(), "string".to_string())],
1456 },
1457 200,
1458 &headers,
1459 r#"{"name": "test"}"#,
1460 )
1461 .0
1462 );
1463
1464 assert!(
1466 !executor
1467 .validate_response(
1468 &CheckValidation::Custom {
1469 expected_status: 200,
1470 expected_headers: vec![],
1471 expected_body_fields: vec![],
1472 },
1473 404,
1474 &headers,
1475 "",
1476 )
1477 .0
1478 );
1479 }
1480
1481 #[test]
1482 fn test_aggregate_results() {
1483 let results = vec![
1484 CheckResult {
1485 name: "check1".to_string(),
1486 passed: true,
1487 failure_detail: None,
1488 },
1489 CheckResult {
1490 name: "check2".to_string(),
1491 passed: false,
1492 failure_detail: Some(FailureDetail {
1493 check: "check2".to_string(),
1494 request: FailureRequest {
1495 method: "GET".to_string(),
1496 url: "http://example.com".to_string(),
1497 headers: HashMap::new(),
1498 body: String::new(),
1499 },
1500 response: FailureResponse {
1501 status: 500,
1502 headers: HashMap::new(),
1503 body: "error".to_string(),
1504 },
1505 expected: "status >= 200 && status < 500".to_string(),
1506 schema_violations: Vec::new(),
1507 }),
1508 },
1509 ];
1510
1511 let report = NativeConformanceExecutor::aggregate(results);
1512 let raw = report.raw_check_results();
1513 assert_eq!(raw.get("check1"), Some(&(1, 0)));
1514 assert_eq!(raw.get("check2"), Some(&(0, 1)));
1515 }
1516
1517 #[test]
1518 fn test_custom_check_building() {
1519 let config = ConformanceConfig {
1520 target_url: "http://localhost:3000".to_string(),
1521 ..Default::default()
1522 };
1523 let mut executor = NativeConformanceExecutor::new(config).unwrap();
1524
1525 let custom = CustomCheck {
1526 name: "custom:test-get".to_string(),
1527 path: "/api/test".to_string(),
1528 method: "GET".to_string(),
1529 expected_status: 200,
1530 body: None,
1531 expected_headers: std::collections::HashMap::new(),
1532 expected_body_fields: vec![],
1533 headers: std::collections::HashMap::new(),
1534 };
1535
1536 executor.add_custom_check(&custom);
1537 assert_eq!(executor.check_count(), 1);
1538 assert_eq!(executor.checks[0].name, "custom:test-get");
1539 }
1540
1541 #[test]
1542 fn test_openapi_schema_to_json_schema_object() {
1543 use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
1544
1545 let schema = Schema {
1546 schema_data: SchemaData::default(),
1547 schema_kind: SchemaKind::Type(Type::Object(ObjectType {
1548 required: vec!["name".to_string()],
1549 ..Default::default()
1550 })),
1551 };
1552
1553 let json = openapi_schema_to_json_schema(&schema);
1554 assert_eq!(json["type"], "object");
1555 assert_eq!(json["required"][0], "name");
1556 }
1557}