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