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(
1013 &self,
1014 schemes: &[SecuritySchemeInfo],
1015 headers: &mut Vec<(String, String)>,
1016 ) {
1017 let mut to_add: Vec<(String, String)> = Vec::new();
1018
1019 for scheme in schemes {
1020 match scheme {
1021 SecuritySchemeInfo::Bearer => {
1022 if !Self::header_present("Authorization", headers, &self.config.custom_headers)
1023 {
1024 to_add.push((
1025 "Authorization".to_string(),
1026 "Bearer mockforge-conformance-test-token".to_string(),
1027 ));
1028 }
1029 }
1030 SecuritySchemeInfo::Basic => {
1031 if !Self::header_present("Authorization", headers, &self.config.custom_headers)
1032 {
1033 let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1034 use base64::Engine;
1035 let encoded =
1036 base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1037 to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1038 }
1039 }
1040 SecuritySchemeInfo::ApiKey { location, name } => match location {
1041 ApiKeyLocation::Header => {
1042 if !Self::header_present(name, headers, &self.config.custom_headers) {
1043 let key = self
1044 .config
1045 .api_key
1046 .as_deref()
1047 .unwrap_or("mockforge-conformance-test-key");
1048 to_add.push((name.clone(), key.to_string()));
1049 }
1050 }
1051 ApiKeyLocation::Cookie => {
1052 if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1053 to_add.push((
1054 "Cookie".to_string(),
1055 format!("{}=mockforge-conformance-test-session", name),
1056 ));
1057 }
1058 }
1059 ApiKeyLocation::Query => {
1060 }
1062 },
1063 }
1064 }
1065
1066 headers.extend(to_add);
1067 }
1068
1069 fn header_present(
1071 name: &str,
1072 headers: &[(String, String)],
1073 custom_headers: &[(String, String)],
1074 ) -> bool {
1075 headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1076 || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1077 }
1078
1079 fn add_custom_check(&mut self, check: &CustomCheck) {
1081 let method = match check.method.to_uppercase().as_str() {
1082 "GET" => Method::GET,
1083 "POST" => Method::POST,
1084 "PUT" => Method::PUT,
1085 "PATCH" => Method::PATCH,
1086 "DELETE" => Method::DELETE,
1087 "HEAD" => Method::HEAD,
1088 "OPTIONS" => Method::OPTIONS,
1089 _ => Method::GET,
1090 };
1091
1092 let mut headers: Vec<(String, String)> =
1094 check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1095 for (k, v) in &self.config.custom_headers {
1097 if !check.headers.contains_key(k) {
1098 headers.push((k.clone(), v.clone()));
1099 }
1100 }
1101 if check.body.is_some()
1103 && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1104 {
1105 headers.push(("Content-Type".to_string(), "application/json".to_string()));
1106 }
1107
1108 let body = check
1110 .body
1111 .as_ref()
1112 .and_then(|b| serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json));
1113
1114 let expected_headers: Vec<(String, String)> =
1116 check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1117
1118 let expected_body_fields: Vec<(String, String)> = check
1120 .expected_body_fields
1121 .iter()
1122 .map(|f| (f.name.clone(), f.field_type.clone()))
1123 .collect();
1124
1125 self.checks.push(ConformanceCheck {
1127 name: check.name.clone(),
1128 method,
1129 path: check.path.clone(),
1130 headers,
1131 body,
1132 validation: CheckValidation::Custom {
1133 expected_status: check.expected_status,
1134 expected_headers,
1135 expected_body_fields,
1136 },
1137 });
1138 }
1139}
1140
1141fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1144 use openapiv3::{SchemaKind, Type};
1145
1146 match &schema.schema_kind {
1147 SchemaKind::Type(Type::Object(obj)) => {
1148 let mut props = serde_json::Map::new();
1149 for (name, prop_ref) in &obj.properties {
1150 if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1151 props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1152 }
1153 }
1154 let mut schema_obj = serde_json::json!({
1155 "type": "object",
1156 "properties": props,
1157 });
1158 if !obj.required.is_empty() {
1159 schema_obj["required"] = serde_json::Value::Array(
1160 obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1161 );
1162 }
1163 schema_obj
1164 }
1165 SchemaKind::Type(Type::Array(arr)) => {
1166 let mut schema_obj = serde_json::json!({"type": "array"});
1167 if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1168 schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1169 }
1170 schema_obj
1171 }
1172 SchemaKind::Type(Type::String(s)) => {
1173 let mut obj = serde_json::json!({"type": "string"});
1174 if let Some(min) = s.min_length {
1175 obj["minLength"] = serde_json::json!(min);
1176 }
1177 if let Some(max) = s.max_length {
1178 obj["maxLength"] = serde_json::json!(max);
1179 }
1180 if let Some(pattern) = &s.pattern {
1181 obj["pattern"] = serde_json::json!(pattern);
1182 }
1183 if !s.enumeration.is_empty() {
1184 obj["enum"] = serde_json::Value::Array(
1185 s.enumeration
1186 .iter()
1187 .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1188 .collect(),
1189 );
1190 }
1191 obj
1192 }
1193 SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1194 SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1195 SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1196 _ => serde_json::json!({}),
1197 }
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202 use super::*;
1203
1204 #[test]
1205 fn test_reference_check_count() {
1206 let config = ConformanceConfig {
1207 target_url: "http://localhost:3000".to_string(),
1208 ..Default::default()
1209 };
1210 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1211 assert_eq!(executor.check_count(), 47);
1214 }
1215
1216 #[test]
1217 fn test_reference_checks_with_category_filter() {
1218 let config = ConformanceConfig {
1219 target_url: "http://localhost:3000".to_string(),
1220 categories: Some(vec!["Parameters".to_string()]),
1221 ..Default::default()
1222 };
1223 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1224 assert_eq!(executor.check_count(), 7);
1225 }
1226
1227 #[test]
1228 fn test_validate_status_range() {
1229 let config = ConformanceConfig {
1230 target_url: "http://localhost:3000".to_string(),
1231 ..Default::default()
1232 };
1233 let executor = NativeConformanceExecutor::new(config).unwrap();
1234 let headers = HashMap::new();
1235
1236 assert!(executor.validate_response(
1237 &CheckValidation::StatusRange {
1238 min: 200,
1239 max_exclusive: 500,
1240 },
1241 200,
1242 &headers,
1243 "",
1244 ));
1245 assert!(executor.validate_response(
1246 &CheckValidation::StatusRange {
1247 min: 200,
1248 max_exclusive: 500,
1249 },
1250 404,
1251 &headers,
1252 "",
1253 ));
1254 assert!(!executor.validate_response(
1255 &CheckValidation::StatusRange {
1256 min: 200,
1257 max_exclusive: 500,
1258 },
1259 500,
1260 &headers,
1261 "",
1262 ));
1263 }
1264
1265 #[test]
1266 fn test_validate_exact_status() {
1267 let config = ConformanceConfig {
1268 target_url: "http://localhost:3000".to_string(),
1269 ..Default::default()
1270 };
1271 let executor = NativeConformanceExecutor::new(config).unwrap();
1272 let headers = HashMap::new();
1273
1274 assert!(executor.validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "",));
1275 assert!(!executor.validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "",));
1276 }
1277
1278 #[test]
1279 fn test_validate_schema() {
1280 let config = ConformanceConfig {
1281 target_url: "http://localhost:3000".to_string(),
1282 ..Default::default()
1283 };
1284 let executor = NativeConformanceExecutor::new(config).unwrap();
1285 let headers = HashMap::new();
1286
1287 let schema = serde_json::json!({
1288 "type": "object",
1289 "properties": {
1290 "name": {"type": "string"},
1291 "age": {"type": "integer"}
1292 },
1293 "required": ["name"]
1294 });
1295
1296 assert!(executor.validate_response(
1297 &CheckValidation::SchemaValidation {
1298 status_min: 200,
1299 status_max: 300,
1300 schema: schema.clone(),
1301 },
1302 200,
1303 &headers,
1304 r#"{"name": "test", "age": 25}"#,
1305 ));
1306
1307 assert!(!executor.validate_response(
1309 &CheckValidation::SchemaValidation {
1310 status_min: 200,
1311 status_max: 300,
1312 schema: schema.clone(),
1313 },
1314 200,
1315 &headers,
1316 r#"{"age": 25}"#,
1317 ));
1318 }
1319
1320 #[test]
1321 fn test_validate_custom() {
1322 let config = ConformanceConfig {
1323 target_url: "http://localhost:3000".to_string(),
1324 ..Default::default()
1325 };
1326 let executor = NativeConformanceExecutor::new(config).unwrap();
1327 let mut headers = HashMap::new();
1328 headers.insert("content-type".to_string(), "application/json".to_string());
1329
1330 assert!(executor.validate_response(
1331 &CheckValidation::Custom {
1332 expected_status: 200,
1333 expected_headers: vec![(
1334 "content-type".to_string(),
1335 "application/json".to_string(),
1336 )],
1337 expected_body_fields: vec![("name".to_string(), "string".to_string())],
1338 },
1339 200,
1340 &headers,
1341 r#"{"name": "test"}"#,
1342 ));
1343
1344 assert!(!executor.validate_response(
1346 &CheckValidation::Custom {
1347 expected_status: 200,
1348 expected_headers: vec![],
1349 expected_body_fields: vec![],
1350 },
1351 404,
1352 &headers,
1353 "",
1354 ));
1355 }
1356
1357 #[test]
1358 fn test_aggregate_results() {
1359 let results = vec![
1360 CheckResult {
1361 name: "check1".to_string(),
1362 passed: true,
1363 failure_detail: None,
1364 },
1365 CheckResult {
1366 name: "check2".to_string(),
1367 passed: false,
1368 failure_detail: Some(FailureDetail {
1369 check: "check2".to_string(),
1370 request: FailureRequest {
1371 method: "GET".to_string(),
1372 url: "http://example.com".to_string(),
1373 headers: HashMap::new(),
1374 body: String::new(),
1375 },
1376 response: FailureResponse {
1377 status: 500,
1378 headers: HashMap::new(),
1379 body: "error".to_string(),
1380 },
1381 expected: "status >= 200 && status < 500".to_string(),
1382 }),
1383 },
1384 ];
1385
1386 let report = NativeConformanceExecutor::aggregate(results);
1387 let raw = report.raw_check_results();
1388 assert_eq!(raw.get("check1"), Some(&(1, 0)));
1389 assert_eq!(raw.get("check2"), Some(&(0, 1)));
1390 }
1391
1392 #[test]
1393 fn test_custom_check_building() {
1394 let config = ConformanceConfig {
1395 target_url: "http://localhost:3000".to_string(),
1396 ..Default::default()
1397 };
1398 let mut executor = NativeConformanceExecutor::new(config).unwrap();
1399
1400 let custom = CustomCheck {
1401 name: "custom:test-get".to_string(),
1402 path: "/api/test".to_string(),
1403 method: "GET".to_string(),
1404 expected_status: 200,
1405 body: None,
1406 expected_headers: std::collections::HashMap::new(),
1407 expected_body_fields: vec![],
1408 headers: std::collections::HashMap::new(),
1409 };
1410
1411 executor.add_custom_check(&custom);
1412 assert_eq!(executor.check_count(), 1);
1413 assert_eq!(executor.checks[0].name, "custom:test-get");
1414 }
1415
1416 #[test]
1417 fn test_openapi_schema_to_json_schema_object() {
1418 use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
1419
1420 let schema = Schema {
1421 schema_data: SchemaData::default(),
1422 schema_kind: SchemaKind::Type(Type::Object(ObjectType {
1423 required: vec!["name".to_string()],
1424 ..Default::default()
1425 })),
1426 };
1427
1428 let json = openapi_schema_to_json_schema(&schema);
1429 assert_eq!(json["type"], "object");
1430 assert_eq!(json["required"][0], "name");
1431 }
1432}