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;
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: HashMap<&'static str, bool> = HashMap::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 let use_qualified =
481 self.config.all_operations || feature_seen.contains_key(check_name_base);
482
483 let check_name = if use_qualified {
484 format!("{}:{}", check_name_base, op.path)
485 } else {
486 check_name_base.to_string()
487 };
488
489 if !self.config.all_operations {
490 feature_seen.insert(check_name_base, true);
491 }
492
493 let check = self.build_spec_check(&check_name, op, feature);
494 self.checks.push(check);
495 }
496 }
497
498 self
499 }
500
501 pub fn with_custom_checks(mut self) -> Result<Self> {
503 let path = match &self.config.custom_checks_file {
504 Some(p) => p.clone(),
505 None => return Ok(self),
506 };
507 let custom_config = CustomConformanceConfig::from_file(&path)?;
508 for check in &custom_config.custom_checks {
509 self.add_custom_check(check);
510 }
511 Ok(self)
512 }
513
514 pub fn check_count(&self) -> usize {
516 self.checks.len()
517 }
518
519 pub async fn execute(&self) -> Result<ConformanceReport> {
521 let mut results = Vec::with_capacity(self.checks.len());
522
523 for check in &self.checks {
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 _ = tx
538 .send(ConformanceProgress::Started {
539 total_checks: total,
540 })
541 .await;
542
543 let mut results = Vec::with_capacity(total);
544
545 for (i, check) in self.checks.iter().enumerate() {
546 let result = self.execute_check(check).await;
547 let passed = result.passed;
548 let name = result.name.clone();
549 results.push(result);
550
551 let _ = tx
552 .send(ConformanceProgress::CheckCompleted {
553 name,
554 passed,
555 checks_done: i + 1,
556 })
557 .await;
558 }
559
560 let _ = tx.send(ConformanceProgress::Finished).await;
561 Ok(Self::aggregate(results))
562 }
563
564 async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
566 let base_url = self.config.effective_base_url();
567 let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
568
569 let mut request = self.client.request(check.method.clone(), &url);
570
571 for (name, value) in &check.headers {
573 request = request.header(name.as_str(), value.as_str());
574 }
575
576 match &check.body {
578 Some(CheckBody::Json(value)) => {
579 request = request.json(value);
580 }
581 Some(CheckBody::FormUrlencoded(fields)) => {
582 request = request.form(fields);
583 }
584 Some(CheckBody::Raw {
585 content,
586 content_type,
587 }) => {
588 if content_type == "text/plain" && check.path.contains("multipart") {
590 let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
591 .file_name("test.txt")
592 .mime_str(content_type)
593 .unwrap_or_else(|_| {
594 reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
595 });
596 let form = reqwest::multipart::Form::new().part("field", part);
597 request = request.multipart(form);
598 } else {
599 request =
600 request.header("Content-Type", content_type.as_str()).body(content.clone());
601 }
602 }
603 None => {}
604 }
605
606 let response = match request.send().await {
607 Ok(resp) => resp,
608 Err(e) => {
609 return CheckResult {
610 name: check.name.clone(),
611 passed: false,
612 failure_detail: Some(FailureDetail {
613 check: check.name.clone(),
614 request: FailureRequest {
615 method: check.method.to_string(),
616 url: url.clone(),
617 headers: HashMap::new(),
618 body: String::new(),
619 },
620 response: FailureResponse {
621 status: 0,
622 headers: HashMap::new(),
623 body: format!("Request failed: {}", e),
624 },
625 expected: format!("{:?}", check.validation),
626 }),
627 };
628 }
629 };
630
631 let status = response.status().as_u16();
632 let resp_headers: HashMap<String, String> = response
633 .headers()
634 .iter()
635 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
636 .collect();
637 let resp_body = response.text().await.unwrap_or_default();
638
639 let passed = self.validate_response(&check.validation, status, &resp_headers, &resp_body);
640
641 let failure_detail = if !passed {
642 Some(FailureDetail {
643 check: check.name.clone(),
644 request: FailureRequest {
645 method: check.method.to_string(),
646 url,
647 headers: check.headers.iter().cloned().collect(),
648 body: match &check.body {
649 Some(CheckBody::Json(v)) => v.to_string(),
650 Some(CheckBody::FormUrlencoded(f)) => f
651 .iter()
652 .map(|(k, v)| format!("{}={}", k, v))
653 .collect::<Vec<_>>()
654 .join("&"),
655 Some(CheckBody::Raw { content, .. }) => content.clone(),
656 None => String::new(),
657 },
658 },
659 response: FailureResponse {
660 status,
661 headers: resp_headers,
662 body: if resp_body.len() > 500 {
663 format!("{}...", &resp_body[..500])
664 } else {
665 resp_body
666 },
667 },
668 expected: Self::describe_validation(&check.validation),
669 })
670 } else {
671 None
672 };
673
674 CheckResult {
675 name: check.name.clone(),
676 passed,
677 failure_detail,
678 }
679 }
680
681 fn validate_response(
683 &self,
684 validation: &CheckValidation,
685 status: u16,
686 headers: &HashMap<String, String>,
687 body: &str,
688 ) -> bool {
689 match validation {
690 CheckValidation::StatusRange { min, max_exclusive } => {
691 status >= *min && status < *max_exclusive
692 }
693 CheckValidation::ExactStatus(expected) => status == *expected,
694 CheckValidation::SchemaValidation {
695 status_min,
696 status_max,
697 schema,
698 } => {
699 if status < *status_min || status >= *status_max {
700 return false;
701 }
702 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
704 return false;
705 };
706 jsonschema::is_valid(schema, &body_value)
707 }
708 CheckValidation::Custom {
709 expected_status,
710 expected_headers,
711 expected_body_fields,
712 } => {
713 if status != *expected_status {
714 return false;
715 }
716 for (header_name, pattern) in expected_headers {
718 let header_val = headers
719 .get(header_name)
720 .or_else(|| headers.get(&header_name.to_lowercase()))
721 .map(|s| s.as_str())
722 .unwrap_or("");
723 if let Ok(re) = regex::Regex::new(pattern) {
724 if !re.is_match(header_val) {
725 return false;
726 }
727 }
728 }
729 if !expected_body_fields.is_empty() {
731 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
732 return false;
733 };
734 for (field_name, field_type) in expected_body_fields {
735 let field = &body_value[field_name];
736 let ok = match field_type.as_str() {
737 "string" => field.is_string(),
738 "integer" => field.is_i64() || field.is_u64(),
739 "number" => field.is_number(),
740 "boolean" => field.is_boolean(),
741 "array" => field.is_array(),
742 "object" => field.is_object(),
743 _ => !field.is_null(),
744 };
745 if !ok {
746 return false;
747 }
748 }
749 }
750 true
751 }
752 }
753 }
754
755 fn describe_validation(validation: &CheckValidation) -> String {
757 match validation {
758 CheckValidation::StatusRange { min, max_exclusive } => {
759 format!("status >= {} && status < {}", min, max_exclusive)
760 }
761 CheckValidation::ExactStatus(code) => format!("status === {}", code),
762 CheckValidation::SchemaValidation {
763 status_min,
764 status_max,
765 ..
766 } => {
767 format!("status >= {} && status < {} + schema validation", status_min, status_max)
768 }
769 CheckValidation::Custom {
770 expected_status, ..
771 } => {
772 format!("status === {}", expected_status)
773 }
774 }
775 }
776
777 fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
779 let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
780 let mut failure_details = Vec::new();
781
782 for result in results {
783 let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
784 if result.passed {
785 entry.0 += 1;
786 } else {
787 entry.1 += 1;
788 }
789 if let Some(detail) = result.failure_detail {
790 failure_details.push(detail);
791 }
792 }
793
794 ConformanceReport::from_results(check_results, failure_details)
795 }
796
797 fn build_spec_check(
801 &self,
802 check_name: &str,
803 op: &AnnotatedOperation,
804 feature: &ConformanceFeature,
805 ) -> ConformanceCheck {
806 let mut url_path = op.path.clone();
808 for (name, value) in &op.path_params {
809 url_path = url_path.replace(&format!("{{{}}}", name), value);
810 }
811 if !op.query_params.is_empty() {
813 let qs: Vec<String> =
814 op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
815 url_path = format!("{}?{}", url_path, qs.join("&"));
816 }
817
818 let mut effective_headers = self.effective_headers(&op.header_params);
820
821 if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
823 let code = match feature {
824 ConformanceFeature::Response400 => "400",
825 ConformanceFeature::Response404 => "404",
826 _ => unreachable!(),
827 };
828 effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
829 }
830
831 let needs_auth = matches!(
833 feature,
834 ConformanceFeature::SecurityBearer
835 | ConformanceFeature::SecurityBasic
836 | ConformanceFeature::SecurityApiKey
837 ) || !op.security_schemes.is_empty();
838
839 if needs_auth {
840 self.inject_security_headers(&op.security_schemes, &mut effective_headers);
841 }
842
843 let method = match op.method.as_str() {
845 "GET" => Method::GET,
846 "POST" => Method::POST,
847 "PUT" => Method::PUT,
848 "PATCH" => Method::PATCH,
849 "DELETE" => Method::DELETE,
850 "HEAD" => Method::HEAD,
851 "OPTIONS" => Method::OPTIONS,
852 _ => Method::GET,
853 };
854
855 let body = match method {
857 Method::POST | Method::PUT | Method::PATCH => {
858 if let Some(sample) = &op.sample_body {
859 let content_type =
861 op.request_body_content_type.as_deref().unwrap_or("application/json");
862 if !effective_headers
863 .iter()
864 .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
865 {
866 effective_headers
867 .push(("Content-Type".to_string(), content_type.to_string()));
868 }
869 match content_type {
870 "application/x-www-form-urlencoded" => {
871 let fields: Vec<(String, String)> = serde_json::from_str::<
873 serde_json::Value,
874 >(
875 sample
876 )
877 .ok()
878 .and_then(|v| {
879 v.as_object().map(|obj| {
880 obj.iter()
881 .map(|(k, v)| {
882 (k.clone(), v.as_str().unwrap_or("").to_string())
883 })
884 .collect()
885 })
886 })
887 .unwrap_or_default();
888 Some(CheckBody::FormUrlencoded(fields))
889 }
890 _ => {
891 match serde_json::from_str::<serde_json::Value>(sample) {
893 Ok(v) => Some(CheckBody::Json(v)),
894 Err(_) => Some(CheckBody::Raw {
895 content: sample.clone(),
896 content_type: content_type.to_string(),
897 }),
898 }
899 }
900 }
901 } else {
902 None
903 }
904 }
905 _ => None,
906 };
907
908 let validation = self.determine_validation(feature, op);
910
911 ConformanceCheck {
912 name: check_name.to_string(),
913 method,
914 path: url_path,
915 headers: effective_headers,
916 body,
917 validation,
918 }
919 }
920
921 fn determine_validation(
923 &self,
924 feature: &ConformanceFeature,
925 op: &AnnotatedOperation,
926 ) -> CheckValidation {
927 match feature {
928 ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
929 ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
930 ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
931 ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
932 ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
933 ConformanceFeature::SecurityBearer
934 | ConformanceFeature::SecurityBasic
935 | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
936 min: 200,
937 max_exclusive: 400,
938 },
939 ConformanceFeature::ResponseValidation => {
940 if let Some(schema) = &op.response_schema {
941 let schema_json = openapi_schema_to_json_schema(schema);
943 CheckValidation::SchemaValidation {
944 status_min: 200,
945 status_max: 500,
946 schema: schema_json,
947 }
948 } else {
949 CheckValidation::StatusRange {
950 min: 200,
951 max_exclusive: 500,
952 }
953 }
954 }
955 _ => CheckValidation::StatusRange {
956 min: 200,
957 max_exclusive: 500,
958 },
959 }
960 }
961
962 fn add_ref_get(&mut self, name: &str, path: &str) {
964 self.checks.push(ConformanceCheck {
965 name: name.to_string(),
966 method: Method::GET,
967 path: path.to_string(),
968 headers: self.custom_headers_only(),
969 body: None,
970 validation: CheckValidation::StatusRange {
971 min: 200,
972 max_exclusive: 500,
973 },
974 });
975 }
976
977 fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
979 let mut headers = Vec::new();
980 for (k, v) in spec_headers {
981 if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
983 continue;
984 }
985 headers.push((k.clone(), v.clone()));
986 }
987 headers.extend(self.config.custom_headers.clone());
989 headers
990 }
991
992 fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
994 for (k, v) in &self.config.custom_headers {
995 if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
996 headers.push((k.clone(), v.clone()));
997 }
998 }
999 headers
1000 }
1001
1002 fn custom_headers_only(&self) -> Vec<(String, String)> {
1004 self.config.custom_headers.clone()
1005 }
1006
1007 fn inject_security_headers(
1009 &self,
1010 schemes: &[SecuritySchemeInfo],
1011 headers: &mut Vec<(String, String)>,
1012 ) {
1013 let mut to_add: Vec<(String, String)> = Vec::new();
1014
1015 for scheme in schemes {
1016 match scheme {
1017 SecuritySchemeInfo::Bearer => {
1018 if !Self::header_present("Authorization", headers, &self.config.custom_headers)
1019 {
1020 to_add.push((
1021 "Authorization".to_string(),
1022 "Bearer mockforge-conformance-test-token".to_string(),
1023 ));
1024 }
1025 }
1026 SecuritySchemeInfo::Basic => {
1027 if !Self::header_present("Authorization", headers, &self.config.custom_headers)
1028 {
1029 let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1030 use base64::Engine;
1031 let encoded =
1032 base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1033 to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1034 }
1035 }
1036 SecuritySchemeInfo::ApiKey { location, name } => match location {
1037 ApiKeyLocation::Header => {
1038 if !Self::header_present(name, headers, &self.config.custom_headers) {
1039 let key = self
1040 .config
1041 .api_key
1042 .as_deref()
1043 .unwrap_or("mockforge-conformance-test-key");
1044 to_add.push((name.clone(), key.to_string()));
1045 }
1046 }
1047 ApiKeyLocation::Cookie => {
1048 if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1049 to_add.push((
1050 "Cookie".to_string(),
1051 format!("{}=mockforge-conformance-test-session", name),
1052 ));
1053 }
1054 }
1055 ApiKeyLocation::Query => {
1056 }
1058 },
1059 }
1060 }
1061
1062 headers.extend(to_add);
1063 }
1064
1065 fn header_present(
1067 name: &str,
1068 headers: &[(String, String)],
1069 custom_headers: &[(String, String)],
1070 ) -> bool {
1071 headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1072 || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1073 }
1074
1075 fn add_custom_check(&mut self, check: &CustomCheck) {
1077 let method = match check.method.to_uppercase().as_str() {
1078 "GET" => Method::GET,
1079 "POST" => Method::POST,
1080 "PUT" => Method::PUT,
1081 "PATCH" => Method::PATCH,
1082 "DELETE" => Method::DELETE,
1083 "HEAD" => Method::HEAD,
1084 "OPTIONS" => Method::OPTIONS,
1085 _ => Method::GET,
1086 };
1087
1088 let mut headers: Vec<(String, String)> =
1090 check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1091 for (k, v) in &self.config.custom_headers {
1093 if !check.headers.contains_key(k) {
1094 headers.push((k.clone(), v.clone()));
1095 }
1096 }
1097 if check.body.is_some()
1099 && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1100 {
1101 headers.push(("Content-Type".to_string(), "application/json".to_string()));
1102 }
1103
1104 let body = check
1106 .body
1107 .as_ref()
1108 .and_then(|b| serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json));
1109
1110 let expected_headers: Vec<(String, String)> =
1112 check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1113
1114 let expected_body_fields: Vec<(String, String)> = check
1116 .expected_body_fields
1117 .iter()
1118 .map(|f| (f.name.clone(), f.field_type.clone()))
1119 .collect();
1120
1121 self.checks.push(ConformanceCheck {
1123 name: check.name.clone(),
1124 method,
1125 path: check.path.clone(),
1126 headers,
1127 body,
1128 validation: CheckValidation::Custom {
1129 expected_status: check.expected_status,
1130 expected_headers,
1131 expected_body_fields,
1132 },
1133 });
1134 }
1135}
1136
1137fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1140 use openapiv3::{SchemaKind, Type};
1141
1142 match &schema.schema_kind {
1143 SchemaKind::Type(Type::Object(obj)) => {
1144 let mut props = serde_json::Map::new();
1145 for (name, prop_ref) in &obj.properties {
1146 if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1147 props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1148 }
1149 }
1150 let mut schema_obj = serde_json::json!({
1151 "type": "object",
1152 "properties": props,
1153 });
1154 if !obj.required.is_empty() {
1155 schema_obj["required"] = serde_json::Value::Array(
1156 obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1157 );
1158 }
1159 schema_obj
1160 }
1161 SchemaKind::Type(Type::Array(arr)) => {
1162 let mut schema_obj = serde_json::json!({"type": "array"});
1163 if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1164 schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1165 }
1166 schema_obj
1167 }
1168 SchemaKind::Type(Type::String(s)) => {
1169 let mut obj = serde_json::json!({"type": "string"});
1170 if let Some(min) = s.min_length {
1171 obj["minLength"] = serde_json::json!(min);
1172 }
1173 if let Some(max) = s.max_length {
1174 obj["maxLength"] = serde_json::json!(max);
1175 }
1176 if let Some(pattern) = &s.pattern {
1177 obj["pattern"] = serde_json::json!(pattern);
1178 }
1179 if !s.enumeration.is_empty() {
1180 obj["enum"] = serde_json::Value::Array(
1181 s.enumeration
1182 .iter()
1183 .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1184 .collect(),
1185 );
1186 }
1187 obj
1188 }
1189 SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1190 SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1191 SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1192 _ => serde_json::json!({}),
1193 }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198 use super::*;
1199
1200 #[test]
1201 fn test_reference_check_count() {
1202 let config = ConformanceConfig {
1203 target_url: "http://localhost:3000".to_string(),
1204 ..Default::default()
1205 };
1206 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1207 assert_eq!(executor.check_count(), 47);
1210 }
1211
1212 #[test]
1213 fn test_reference_checks_with_category_filter() {
1214 let config = ConformanceConfig {
1215 target_url: "http://localhost:3000".to_string(),
1216 categories: Some(vec!["Parameters".to_string()]),
1217 ..Default::default()
1218 };
1219 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1220 assert_eq!(executor.check_count(), 7);
1221 }
1222
1223 #[test]
1224 fn test_validate_status_range() {
1225 let config = ConformanceConfig {
1226 target_url: "http://localhost:3000".to_string(),
1227 ..Default::default()
1228 };
1229 let executor = NativeConformanceExecutor::new(config).unwrap();
1230 let headers = HashMap::new();
1231
1232 assert!(executor.validate_response(
1233 &CheckValidation::StatusRange {
1234 min: 200,
1235 max_exclusive: 500,
1236 },
1237 200,
1238 &headers,
1239 "",
1240 ));
1241 assert!(executor.validate_response(
1242 &CheckValidation::StatusRange {
1243 min: 200,
1244 max_exclusive: 500,
1245 },
1246 404,
1247 &headers,
1248 "",
1249 ));
1250 assert!(!executor.validate_response(
1251 &CheckValidation::StatusRange {
1252 min: 200,
1253 max_exclusive: 500,
1254 },
1255 500,
1256 &headers,
1257 "",
1258 ));
1259 }
1260
1261 #[test]
1262 fn test_validate_exact_status() {
1263 let config = ConformanceConfig {
1264 target_url: "http://localhost:3000".to_string(),
1265 ..Default::default()
1266 };
1267 let executor = NativeConformanceExecutor::new(config).unwrap();
1268 let headers = HashMap::new();
1269
1270 assert!(executor.validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "",));
1271 assert!(!executor.validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "",));
1272 }
1273
1274 #[test]
1275 fn test_validate_schema() {
1276 let config = ConformanceConfig {
1277 target_url: "http://localhost:3000".to_string(),
1278 ..Default::default()
1279 };
1280 let executor = NativeConformanceExecutor::new(config).unwrap();
1281 let headers = HashMap::new();
1282
1283 let schema = serde_json::json!({
1284 "type": "object",
1285 "properties": {
1286 "name": {"type": "string"},
1287 "age": {"type": "integer"}
1288 },
1289 "required": ["name"]
1290 });
1291
1292 assert!(executor.validate_response(
1293 &CheckValidation::SchemaValidation {
1294 status_min: 200,
1295 status_max: 300,
1296 schema: schema.clone(),
1297 },
1298 200,
1299 &headers,
1300 r#"{"name": "test", "age": 25}"#,
1301 ));
1302
1303 assert!(!executor.validate_response(
1305 &CheckValidation::SchemaValidation {
1306 status_min: 200,
1307 status_max: 300,
1308 schema: schema.clone(),
1309 },
1310 200,
1311 &headers,
1312 r#"{"age": 25}"#,
1313 ));
1314 }
1315
1316 #[test]
1317 fn test_validate_custom() {
1318 let config = ConformanceConfig {
1319 target_url: "http://localhost:3000".to_string(),
1320 ..Default::default()
1321 };
1322 let executor = NativeConformanceExecutor::new(config).unwrap();
1323 let mut headers = HashMap::new();
1324 headers.insert("content-type".to_string(), "application/json".to_string());
1325
1326 assert!(executor.validate_response(
1327 &CheckValidation::Custom {
1328 expected_status: 200,
1329 expected_headers: vec![(
1330 "content-type".to_string(),
1331 "application/json".to_string(),
1332 )],
1333 expected_body_fields: vec![("name".to_string(), "string".to_string())],
1334 },
1335 200,
1336 &headers,
1337 r#"{"name": "test"}"#,
1338 ));
1339
1340 assert!(!executor.validate_response(
1342 &CheckValidation::Custom {
1343 expected_status: 200,
1344 expected_headers: vec![],
1345 expected_body_fields: vec![],
1346 },
1347 404,
1348 &headers,
1349 "",
1350 ));
1351 }
1352
1353 #[test]
1354 fn test_aggregate_results() {
1355 let results = vec![
1356 CheckResult {
1357 name: "check1".to_string(),
1358 passed: true,
1359 failure_detail: None,
1360 },
1361 CheckResult {
1362 name: "check2".to_string(),
1363 passed: false,
1364 failure_detail: Some(FailureDetail {
1365 check: "check2".to_string(),
1366 request: FailureRequest {
1367 method: "GET".to_string(),
1368 url: "http://example.com".to_string(),
1369 headers: HashMap::new(),
1370 body: String::new(),
1371 },
1372 response: FailureResponse {
1373 status: 500,
1374 headers: HashMap::new(),
1375 body: "error".to_string(),
1376 },
1377 expected: "status >= 200 && status < 500".to_string(),
1378 }),
1379 },
1380 ];
1381
1382 let report = NativeConformanceExecutor::aggregate(results);
1383 let raw = report.raw_check_results();
1384 assert_eq!(raw.get("check1"), Some(&(1, 0)));
1385 assert_eq!(raw.get("check2"), Some(&(0, 1)));
1386 }
1387
1388 #[test]
1389 fn test_custom_check_building() {
1390 let config = ConformanceConfig {
1391 target_url: "http://localhost:3000".to_string(),
1392 ..Default::default()
1393 };
1394 let mut executor = NativeConformanceExecutor::new(config).unwrap();
1395
1396 let custom = CustomCheck {
1397 name: "custom:test-get".to_string(),
1398 path: "/api/test".to_string(),
1399 method: "GET".to_string(),
1400 expected_status: 200,
1401 body: None,
1402 expected_headers: std::collections::HashMap::new(),
1403 expected_body_fields: vec![],
1404 headers: std::collections::HashMap::new(),
1405 };
1406
1407 executor.add_custom_check(&custom);
1408 assert_eq!(executor.check_count(), 1);
1409 assert_eq!(executor.checks[0].name, "custom:test-get");
1410 }
1411
1412 #[test]
1413 fn test_openapi_schema_to_json_schema_object() {
1414 use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
1415
1416 let schema = Schema {
1417 schema_data: SchemaData::default(),
1418 schema_kind: SchemaKind::Type(Type::Object(ObjectType {
1419 required: vec!["name".to_string()],
1420 ..Default::default()
1421 })),
1422 };
1423
1424 let json = openapi_schema_to_json_schema(&schema);
1425 assert_eq!(json["type"], "object");
1426 assert_eq!(json["required"][0], "name");
1427 }
1428}