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