1use super::custom::{CustomCheck, CustomConformanceConfig};
8use super::generator::ConformanceConfig;
9use super::report::{ConformanceReport, FailureDetail, FailureRequest, FailureResponse};
10use super::spec::ConformanceFeature;
11use super::spec_driven::{AnnotatedOperation, ApiKeyLocation, SecuritySchemeInfo};
12use crate::error::{BenchError, Result};
13use reqwest::{Client, Method};
14use std::collections::{HashMap, HashSet};
15use std::time::Duration;
16use tokio::sync::mpsc;
17
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub struct SchemaViolation {
21 pub field_path: String,
23 pub violation_type: String,
25 pub expected: String,
27 pub actual: String,
29}
30
31#[derive(Debug, Clone)]
33pub struct ConformanceCheck {
34 pub name: String,
36 pub method: Method,
38 pub path: String,
40 pub headers: Vec<(String, String)>,
42 pub body: Option<CheckBody>,
44 pub validation: CheckValidation,
46}
47
48#[derive(Debug, Clone)]
50pub enum CheckBody {
51 Json(serde_json::Value),
53 FormUrlencoded(Vec<(String, String)>),
55 Raw {
57 content: String,
58 content_type: String,
59 },
60}
61
62#[derive(Debug, Clone)]
64pub enum CheckValidation {
65 StatusRange { min: u16, max_exclusive: u16 },
67 ExactStatus(u16),
69 SchemaValidation {
71 status_min: u16,
72 status_max: u16,
73 schema: serde_json::Value,
74 },
75 Custom {
77 expected_status: u16,
78 expected_headers: Vec<(String, String)>,
79 expected_body_fields: Vec<(String, String)>,
80 },
81}
82
83#[derive(Debug, Clone, serde::Serialize)]
85#[serde(tag = "type")]
86pub enum ConformanceProgress {
87 #[serde(rename = "started")]
89 Started { total_checks: usize },
90 #[serde(rename = "check_completed")]
92 CheckCompleted {
93 name: String,
94 passed: bool,
95 checks_done: usize,
96 },
97 #[serde(rename = "finished")]
99 Finished,
100 #[serde(rename = "error")]
102 Error { message: String },
103}
104
105#[derive(Debug)]
107struct CheckResult {
108 name: String,
109 passed: bool,
110 failure_detail: Option<FailureDetail>,
111 captured: Option<CapturedExchange>,
114}
115
116#[derive(Debug, serde::Serialize)]
118struct CapturedExchange {
119 method: String,
120 url: String,
121 request_headers: HashMap<String, String>,
122 request_body: String,
123 response_status: u16,
124 response_headers: HashMap<String, String>,
125 response_body: String,
126}
127
128pub struct NativeConformanceExecutor {
130 config: ConformanceConfig,
131 client: Client,
132 checks: Vec<ConformanceCheck>,
133}
134
135impl NativeConformanceExecutor {
136 pub fn new(config: ConformanceConfig) -> Result<Self> {
138 let mut builder = Client::builder()
139 .timeout(Duration::from_secs(30))
140 .connect_timeout(Duration::from_secs(10));
141
142 if config.skip_tls_verify {
143 builder = builder.danger_accept_invalid_certs(true);
144 }
145
146 let client = builder
147 .build()
148 .map_err(|e| BenchError::Other(format!("Failed to build HTTP client: {}", e)))?;
149
150 Ok(Self {
151 config,
152 client,
153 checks: Vec::new(),
154 })
155 }
156
157 #[must_use]
160 pub fn with_reference_checks(mut self) -> Self {
161 if self.config.should_include_category("Parameters") {
163 self.add_ref_get("param:path:string", "/conformance/params/hello");
164 self.add_ref_get("param:path:integer", "/conformance/params/42");
165 self.add_ref_get("param:query:string", "/conformance/params/query?name=test");
166 self.add_ref_get("param:query:integer", "/conformance/params/query?count=10");
167 self.add_ref_get("param:query:array", "/conformance/params/query?tags=a&tags=b");
168 self.checks.push(ConformanceCheck {
169 name: "param:header".to_string(),
170 method: Method::GET,
171 path: "/conformance/params/header".to_string(),
172 headers: self
173 .merge_headers(vec![("X-Custom-Param".to_string(), "test-value".to_string())]),
174 body: None,
175 validation: CheckValidation::StatusRange {
176 min: 200,
177 max_exclusive: 500,
178 },
179 });
180 self.checks.push(ConformanceCheck {
181 name: "param:cookie".to_string(),
182 method: Method::GET,
183 path: "/conformance/params/cookie".to_string(),
184 headers: self
185 .merge_headers(vec![("Cookie".to_string(), "session=abc123".to_string())]),
186 body: None,
187 validation: CheckValidation::StatusRange {
188 min: 200,
189 max_exclusive: 500,
190 },
191 });
192 }
193
194 if self.config.should_include_category("Request Bodies") {
196 self.checks.push(ConformanceCheck {
197 name: "body:json".to_string(),
198 method: Method::POST,
199 path: "/conformance/body/json".to_string(),
200 headers: self.merge_headers(vec![(
201 "Content-Type".to_string(),
202 "application/json".to_string(),
203 )]),
204 body: Some(CheckBody::Json(serde_json::json!({"name": "test", "value": 42}))),
205 validation: CheckValidation::StatusRange {
206 min: 200,
207 max_exclusive: 500,
208 },
209 });
210 self.checks.push(ConformanceCheck {
211 name: "body:form-urlencoded".to_string(),
212 method: Method::POST,
213 path: "/conformance/body/form".to_string(),
214 headers: self.custom_headers_only(),
215 body: Some(CheckBody::FormUrlencoded(vec![
216 ("field1".to_string(), "value1".to_string()),
217 ("field2".to_string(), "value2".to_string()),
218 ])),
219 validation: CheckValidation::StatusRange {
220 min: 200,
221 max_exclusive: 500,
222 },
223 });
224 self.checks.push(ConformanceCheck {
225 name: "body:multipart".to_string(),
226 method: Method::POST,
227 path: "/conformance/body/multipart".to_string(),
228 headers: self.custom_headers_only(),
229 body: Some(CheckBody::Raw {
230 content: "test content".to_string(),
231 content_type: "text/plain".to_string(),
232 }),
233 validation: CheckValidation::StatusRange {
234 min: 200,
235 max_exclusive: 500,
236 },
237 });
238 }
239
240 if self.config.should_include_category("Schema Types") {
242 let types = [
243 ("string", r#"{"value": "hello"}"#, "schema:string"),
244 ("integer", r#"{"value": 42}"#, "schema:integer"),
245 ("number", r#"{"value": 3.14}"#, "schema:number"),
246 ("boolean", r#"{"value": true}"#, "schema:boolean"),
247 ("array", r#"{"value": [1, 2, 3]}"#, "schema:array"),
248 ("object", r#"{"value": {"nested": "data"}}"#, "schema:object"),
249 ];
250 for (type_name, body_str, check_name) in types {
251 self.checks.push(ConformanceCheck {
252 name: check_name.to_string(),
253 method: Method::POST,
254 path: format!("/conformance/schema/{}", type_name),
255 headers: self.merge_headers(vec![(
256 "Content-Type".to_string(),
257 "application/json".to_string(),
258 )]),
259 body: Some(CheckBody::Json(
260 serde_json::from_str(body_str).expect("valid JSON"),
261 )),
262 validation: CheckValidation::StatusRange {
263 min: 200,
264 max_exclusive: 500,
265 },
266 });
267 }
268 }
269
270 if self.config.should_include_category("Composition") {
272 let compositions = [
273 ("oneOf", r#"{"type": "string", "value": "test"}"#, "composition:oneOf"),
274 ("anyOf", r#"{"value": "test"}"#, "composition:anyOf"),
275 ("allOf", r#"{"name": "test", "id": 1}"#, "composition:allOf"),
276 ];
277 for (kind, body_str, check_name) in compositions {
278 self.checks.push(ConformanceCheck {
279 name: check_name.to_string(),
280 method: Method::POST,
281 path: format!("/conformance/composition/{}", kind),
282 headers: self.merge_headers(vec![(
283 "Content-Type".to_string(),
284 "application/json".to_string(),
285 )]),
286 body: Some(CheckBody::Json(
287 serde_json::from_str(body_str).expect("valid JSON"),
288 )),
289 validation: CheckValidation::StatusRange {
290 min: 200,
291 max_exclusive: 500,
292 },
293 });
294 }
295 }
296
297 if self.config.should_include_category("String Formats") {
299 let formats = [
300 ("date", r#"{"value": "2024-01-15"}"#, "format:date"),
301 ("date-time", r#"{"value": "2024-01-15T10:30:00Z"}"#, "format:date-time"),
302 ("email", r#"{"value": "test@example.com"}"#, "format:email"),
303 ("uuid", r#"{"value": "550e8400-e29b-41d4-a716-446655440000"}"#, "format:uuid"),
304 ("uri", r#"{"value": "https://example.com/path"}"#, "format:uri"),
305 ("ipv4", r#"{"value": "192.168.1.1"}"#, "format:ipv4"),
306 ("ipv6", r#"{"value": "::1"}"#, "format:ipv6"),
307 ];
308 for (fmt, body_str, check_name) in formats {
309 self.checks.push(ConformanceCheck {
310 name: check_name.to_string(),
311 method: Method::POST,
312 path: format!("/conformance/formats/{}", fmt),
313 headers: self.merge_headers(vec![(
314 "Content-Type".to_string(),
315 "application/json".to_string(),
316 )]),
317 body: Some(CheckBody::Json(
318 serde_json::from_str(body_str).expect("valid JSON"),
319 )),
320 validation: CheckValidation::StatusRange {
321 min: 200,
322 max_exclusive: 500,
323 },
324 });
325 }
326 }
327
328 if self.config.should_include_category("Constraints") {
330 let constraints = [
331 ("required", r#"{"required_field": "present"}"#, "constraint:required"),
332 ("optional", r#"{}"#, "constraint:optional"),
333 ("minmax", r#"{"value": 50}"#, "constraint:minmax"),
334 ("pattern", r#"{"value": "ABC-123"}"#, "constraint:pattern"),
335 ("enum", r#"{"status": "active"}"#, "constraint:enum"),
336 ];
337 for (kind, body_str, check_name) in constraints {
338 self.checks.push(ConformanceCheck {
339 name: check_name.to_string(),
340 method: Method::POST,
341 path: format!("/conformance/constraints/{}", kind),
342 headers: self.merge_headers(vec![(
343 "Content-Type".to_string(),
344 "application/json".to_string(),
345 )]),
346 body: Some(CheckBody::Json(
347 serde_json::from_str(body_str).expect("valid JSON"),
348 )),
349 validation: CheckValidation::StatusRange {
350 min: 200,
351 max_exclusive: 500,
352 },
353 });
354 }
355 }
356
357 if self.config.should_include_category("Response Codes") {
359 for (code_str, check_name) in [
360 ("200", "response:200"),
361 ("201", "response:201"),
362 ("204", "response:204"),
363 ("400", "response:400"),
364 ("404", "response:404"),
365 ] {
366 let code: u16 = code_str.parse().unwrap();
367 self.checks.push(ConformanceCheck {
368 name: check_name.to_string(),
369 method: Method::GET,
370 path: format!("/conformance/responses/{}", code_str),
371 headers: self.custom_headers_only(),
372 body: None,
373 validation: CheckValidation::ExactStatus(code),
374 });
375 }
376 }
377
378 if self.config.should_include_category("HTTP Methods") {
380 self.add_ref_get("method:GET", "/conformance/methods");
381 for (method, check_name) in [
382 (Method::POST, "method:POST"),
383 (Method::PUT, "method:PUT"),
384 (Method::PATCH, "method:PATCH"),
385 ] {
386 self.checks.push(ConformanceCheck {
387 name: check_name.to_string(),
388 method,
389 path: "/conformance/methods".to_string(),
390 headers: self.merge_headers(vec![(
391 "Content-Type".to_string(),
392 "application/json".to_string(),
393 )]),
394 body: Some(CheckBody::Json(serde_json::json!({"action": "test"}))),
395 validation: CheckValidation::StatusRange {
396 min: 200,
397 max_exclusive: 500,
398 },
399 });
400 }
401 for (method, check_name) in [
402 (Method::DELETE, "method:DELETE"),
403 (Method::HEAD, "method:HEAD"),
404 (Method::OPTIONS, "method:OPTIONS"),
405 ] {
406 self.checks.push(ConformanceCheck {
407 name: check_name.to_string(),
408 method,
409 path: "/conformance/methods".to_string(),
410 headers: self.custom_headers_only(),
411 body: None,
412 validation: CheckValidation::StatusRange {
413 min: 200,
414 max_exclusive: 500,
415 },
416 });
417 }
418 }
419
420 if self.config.should_include_category("Content Types") {
422 self.checks.push(ConformanceCheck {
423 name: "content:negotiation".to_string(),
424 method: Method::GET,
425 path: "/conformance/content-types".to_string(),
426 headers: self
427 .merge_headers(vec![("Accept".to_string(), "application/json".to_string())]),
428 body: None,
429 validation: CheckValidation::StatusRange {
430 min: 200,
431 max_exclusive: 500,
432 },
433 });
434 }
435
436 if self.config.should_include_category("Security") {
438 self.checks.push(ConformanceCheck {
440 name: "security:bearer".to_string(),
441 method: Method::GET,
442 path: "/conformance/security/bearer".to_string(),
443 headers: self.merge_headers(vec![(
444 "Authorization".to_string(),
445 "Bearer test-token-123".to_string(),
446 )]),
447 body: None,
448 validation: CheckValidation::StatusRange {
449 min: 200,
450 max_exclusive: 500,
451 },
452 });
453
454 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
456 self.checks.push(ConformanceCheck {
457 name: "security:apikey".to_string(),
458 method: Method::GET,
459 path: "/conformance/security/apikey".to_string(),
460 headers: self.merge_headers(vec![("X-API-Key".to_string(), api_key.to_string())]),
461 body: None,
462 validation: CheckValidation::StatusRange {
463 min: 200,
464 max_exclusive: 500,
465 },
466 });
467
468 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
470 use base64::Engine;
471 let encoded = base64::engine::general_purpose::STANDARD.encode(basic_creds.as_bytes());
472 self.checks.push(ConformanceCheck {
473 name: "security:basic".to_string(),
474 method: Method::GET,
475 path: "/conformance/security/basic".to_string(),
476 headers: self.merge_headers(vec![(
477 "Authorization".to_string(),
478 format!("Basic {}", encoded),
479 )]),
480 body: None,
481 validation: CheckValidation::StatusRange {
482 min: 200,
483 max_exclusive: 500,
484 },
485 });
486 }
487
488 self
489 }
490
491 #[must_use]
493 pub fn with_spec_driven_checks(mut self, operations: &[AnnotatedOperation]) -> Self {
494 let mut feature_seen: HashSet<&'static str> = HashSet::new();
496
497 for op in operations {
498 for feature in &op.features {
499 let category = feature.category();
500 if !self.config.should_include_category(category) {
501 continue;
502 }
503
504 let check_name_base = feature.check_name();
505
506 if self.config.all_operations {
507 let check_name = format!("{}:{}", check_name_base, op.path);
509 let check = self.build_spec_check(&check_name, op, feature);
510 self.checks.push(check);
511 } else {
512 if feature_seen.insert(check_name_base) {
514 let check_name = format!("{}:{}", check_name_base, op.path);
515 let check = self.build_spec_check(&check_name, op, feature);
516 self.checks.push(check);
517 }
518 }
519 }
520 }
521
522 self
523 }
524
525 pub fn with_custom_checks(mut self) -> Result<Self> {
527 let path = match &self.config.custom_checks_file {
528 Some(p) => p.clone(),
529 None => return Ok(self),
530 };
531 let custom_config = CustomConformanceConfig::from_file(&path)?;
532
533 let filter_re = match &self.config.custom_filter {
535 Some(pattern) => Some(regex::Regex::new(pattern).map_err(|e| {
536 BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
537 })?),
538 None => None,
539 };
540
541 let mut included = 0usize;
542 let total = custom_config.custom_checks.len();
543 for check in &custom_config.custom_checks {
544 if let Some(ref re) = filter_re {
545 if !re.is_match(&check.name) && !re.is_match(&check.path) {
546 continue;
547 }
548 }
549 self.add_custom_check(check);
550 included += 1;
551 }
552
553 if filter_re.is_some() {
554 tracing::info!("Custom check filter: {}/{} checks matched pattern", included, total);
555 }
556
557 Ok(self)
558 }
559
560 pub fn check_count(&self) -> usize {
562 self.checks.len()
563 }
564
565 pub async fn execute(&self) -> Result<ConformanceReport> {
567 let mut results = Vec::with_capacity(self.checks.len());
568 let delay = self.config.request_delay_ms;
569
570 for (i, check) in self.checks.iter().enumerate() {
571 if delay > 0 && i > 0 {
572 tokio::time::sleep(Duration::from_millis(delay)).await;
573 }
574 results.push(self.execute_check(check).await);
575 }
576
577 if self.config.export_requests {
579 if let Some(ref output_dir) = self.config.output_dir {
580 let request_log: Vec<_> = results
581 .iter()
582 .filter_map(|r| {
583 r.captured.as_ref().map(|c| {
584 serde_json::json!({
585 "check": r.name,
586 "passed": r.passed,
587 "request": {
588 "method": c.method,
589 "url": c.url,
590 "headers": c.request_headers,
591 "body": c.request_body,
592 },
593 "response": {
594 "status": c.response_status,
595 "headers": c.response_headers,
596 "body": c.response_body,
597 },
598 })
599 })
600 })
601 .collect();
602 let path = output_dir.join("conformance-requests.json");
603 if let Ok(json) = serde_json::to_string_pretty(&request_log) {
604 let _ = std::fs::write(&path, json);
605 tracing::info!(
606 "Exported {} request/response pairs to {}",
607 request_log.len(),
608 path.display()
609 );
610 }
611 }
612 }
613
614 Ok(Self::aggregate(results))
615 }
616
617 pub async fn execute_with_progress(
619 &self,
620 tx: mpsc::Sender<ConformanceProgress>,
621 ) -> Result<ConformanceReport> {
622 let total = self.checks.len();
623 let delay = self.config.request_delay_ms;
624 let _ = tx
625 .send(ConformanceProgress::Started {
626 total_checks: total,
627 })
628 .await;
629
630 let mut results = Vec::with_capacity(total);
631
632 for (i, check) in self.checks.iter().enumerate() {
633 if delay > 0 && i > 0 {
634 tokio::time::sleep(Duration::from_millis(delay)).await;
635 }
636 let result = self.execute_check(check).await;
637 let passed = result.passed;
638 let name = result.name.clone();
639 results.push(result);
640
641 let _ = tx
642 .send(ConformanceProgress::CheckCompleted {
643 name,
644 passed,
645 checks_done: i + 1,
646 })
647 .await;
648 }
649
650 let _ = tx.send(ConformanceProgress::Finished).await;
651 Ok(Self::aggregate(results))
652 }
653
654 async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
656 let base_url = self.config.effective_base_url();
657 let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
658
659 let mut request = self.client.request(check.method.clone(), &url);
660
661 for (name, value) in &check.headers {
663 request = request.header(name.as_str(), value.as_str());
664 }
665
666 match &check.body {
668 Some(CheckBody::Json(value)) => {
669 request = request.json(value);
670 }
671 Some(CheckBody::FormUrlencoded(fields)) => {
672 request = request.form(fields);
673 }
674 Some(CheckBody::Raw {
675 content,
676 content_type,
677 }) => {
678 if content_type == "text/plain" && check.path.contains("multipart") {
680 let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
681 .file_name("test.txt")
682 .mime_str(content_type)
683 .unwrap_or_else(|_| {
684 reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
685 });
686 let form = reqwest::multipart::Form::new().part("field", part);
687 request = request.multipart(form);
688 } else {
689 request =
690 request.header("Content-Type", content_type.as_str()).body(content.clone());
691 }
692 }
693 None => {}
694 }
695
696 let req_body_str = match &check.body {
697 Some(CheckBody::Json(v)) => v.to_string(),
698 Some(CheckBody::FormUrlencoded(f)) => {
699 f.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&")
700 }
701 Some(CheckBody::Raw { content, .. }) => content.clone(),
702 None => String::new(),
703 };
704
705 let response = match request.send().await {
706 Ok(resp) => resp,
707 Err(e) => {
708 return CheckResult {
709 name: check.name.clone(),
710 passed: false,
711 failure_detail: Some(FailureDetail {
712 check: check.name.clone(),
713 request: FailureRequest {
714 method: check.method.to_string(),
715 url: url.clone(),
716 headers: HashMap::new(),
717 body: String::new(),
718 },
719 response: FailureResponse {
720 status: 0,
721 headers: HashMap::new(),
722 body: format!("Request failed: {}", e),
723 },
724 expected: format!("{:?}", check.validation),
725 schema_violations: Vec::new(),
726 }),
727 captured: None,
728 };
729 }
730 };
731
732 let status = response.status().as_u16();
733 let resp_headers: HashMap<String, String> = response
734 .headers()
735 .iter()
736 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
737 .collect();
738 let resp_body = response.text().await.unwrap_or_default();
739
740 let (passed, schema_violations) =
741 self.validate_response(&check.validation, status, &resp_headers, &resp_body);
742
743 let captured = if self.config.export_requests {
745 Some(CapturedExchange {
746 method: check.method.to_string(),
747 url: url.clone(),
748 request_headers: check.headers.iter().cloned().collect(),
749 request_body: req_body_str,
750 response_status: status,
751 response_headers: resp_headers.clone(),
752 response_body: if resp_body.len() > 2000 {
753 format!("{}...(truncated)", &resp_body[..2000])
754 } else {
755 resp_body.clone()
756 },
757 })
758 } else {
759 None
760 };
761
762 let failure_detail = if !passed {
763 Some(FailureDetail {
764 check: check.name.clone(),
765 request: FailureRequest {
766 method: check.method.to_string(),
767 url,
768 headers: check.headers.iter().cloned().collect(),
769 body: match &check.body {
770 Some(CheckBody::Json(v)) => v.to_string(),
771 Some(CheckBody::FormUrlencoded(f)) => f
772 .iter()
773 .map(|(k, v)| format!("{}={}", k, v))
774 .collect::<Vec<_>>()
775 .join("&"),
776 Some(CheckBody::Raw { content, .. }) => content.clone(),
777 None => String::new(),
778 },
779 },
780 response: FailureResponse {
781 status,
782 headers: resp_headers,
783 body: if resp_body.len() > 500 {
784 format!("{}...", &resp_body[..500])
785 } else {
786 resp_body
787 },
788 },
789 expected: Self::describe_validation(&check.validation),
790 schema_violations,
791 })
792 } else {
793 None
794 };
795
796 CheckResult {
797 name: check.name.clone(),
798 passed,
799 failure_detail,
800 captured,
801 }
802 }
803
804 fn validate_response(
809 &self,
810 validation: &CheckValidation,
811 status: u16,
812 headers: &HashMap<String, String>,
813 body: &str,
814 ) -> (bool, Vec<SchemaViolation>) {
815 match validation {
816 CheckValidation::StatusRange { min, max_exclusive } => {
817 (status >= *min && status < *max_exclusive, Vec::new())
818 }
819 CheckValidation::ExactStatus(expected) => (status == *expected, Vec::new()),
820 CheckValidation::SchemaValidation {
821 status_min,
822 status_max,
823 schema,
824 } => {
825 if status < *status_min || status >= *status_max {
826 return (false, Vec::new());
827 }
828 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
830 return (
831 false,
832 vec![SchemaViolation {
833 field_path: "/".to_string(),
834 violation_type: "parse_error".to_string(),
835 expected: "valid JSON".to_string(),
836 actual: "non-JSON response body".to_string(),
837 }],
838 );
839 };
840 match jsonschema::validator_for(schema) {
841 Ok(validator) => {
842 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
843 if errors.is_empty() {
844 (true, Vec::new())
845 } else {
846 let violations = errors
847 .iter()
848 .map(|err| {
849 let field_path = err.instance_path.to_string();
850 let field_path = if field_path.is_empty() {
851 "/".to_string()
852 } else {
853 field_path
854 };
855 SchemaViolation {
856 field_path,
857 violation_type: format!("{:?}", err.kind)
858 .split('(')
859 .next()
860 .unwrap_or("unknown")
861 .split('{')
862 .next()
863 .unwrap_or("unknown")
864 .split(' ')
865 .next()
866 .unwrap_or("unknown")
867 .trim()
868 .to_string(),
869 expected: {
870 let schema_str = format!("{}", err.schema_path);
874 match &err.kind {
875 jsonschema::error::ValidationErrorKind::Type { kind } => {
876 format!("type: {:?}", kind)
877 }
878 jsonschema::error::ValidationErrorKind::Required { property } => {
879 format!("required field: {}", property)
880 }
881 _ => {
882 schema_str
884 .rsplit('/')
885 .next()
886 .unwrap_or(&schema_str)
887 .to_string()
888 }
889 }
890 },
891 actual: format!("{}", err),
892 }
893 })
894 .collect();
895 (false, violations)
896 }
897 }
898 Err(_) => {
899 (
901 false,
902 vec![SchemaViolation {
903 field_path: "/".to_string(),
904 violation_type: "schema_compile_error".to_string(),
905 expected: "valid JSON schema".to_string(),
906 actual: "schema failed to compile".to_string(),
907 }],
908 )
909 }
910 }
911 }
912 CheckValidation::Custom {
913 expected_status,
914 expected_headers,
915 expected_body_fields,
916 } => {
917 if status != *expected_status {
918 return (false, Vec::new());
919 }
920 for (header_name, pattern) in expected_headers {
922 let header_val = headers
923 .get(header_name)
924 .or_else(|| headers.get(&header_name.to_lowercase()))
925 .map(|s| s.as_str())
926 .unwrap_or("");
927 if let Ok(re) = regex::Regex::new(pattern) {
928 if !re.is_match(header_val) {
929 return (false, Vec::new());
930 }
931 }
932 }
933 if !expected_body_fields.is_empty() {
935 let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
936 return (false, Vec::new());
937 };
938 for (field_name, field_type) in expected_body_fields {
939 let field = &body_value[field_name];
940 let ok = match field_type.as_str() {
941 "string" => field.is_string(),
942 "integer" => field.is_i64() || field.is_u64(),
943 "number" => field.is_number(),
944 "boolean" => field.is_boolean(),
945 "array" => field.is_array(),
946 "object" => field.is_object(),
947 _ => !field.is_null(),
948 };
949 if !ok {
950 return (false, Vec::new());
951 }
952 }
953 }
954 (true, Vec::new())
955 }
956 }
957 }
958
959 fn describe_validation(validation: &CheckValidation) -> String {
961 match validation {
962 CheckValidation::StatusRange { min, max_exclusive } => {
963 format!("status >= {} && status < {}", min, max_exclusive)
964 }
965 CheckValidation::ExactStatus(code) => format!("status === {}", code),
966 CheckValidation::SchemaValidation {
967 status_min,
968 status_max,
969 ..
970 } => {
971 format!("status >= {} && status < {} + schema validation", status_min, status_max)
972 }
973 CheckValidation::Custom {
974 expected_status, ..
975 } => {
976 format!("status === {}", expected_status)
977 }
978 }
979 }
980
981 fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
983 let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
984 let mut failure_details = Vec::new();
985
986 for result in results {
987 let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
988 if result.passed {
989 entry.0 += 1;
990 } else {
991 entry.1 += 1;
992 }
993 if let Some(detail) = result.failure_detail {
994 failure_details.push(detail);
995 }
996 }
997
998 ConformanceReport::from_results(check_results, failure_details)
999 }
1000
1001 fn build_spec_check(
1005 &self,
1006 check_name: &str,
1007 op: &AnnotatedOperation,
1008 feature: &ConformanceFeature,
1009 ) -> ConformanceCheck {
1010 let mut url_path = op.path.clone();
1012 for (name, value) in &op.path_params {
1013 url_path = url_path.replace(&format!("{{{}}}", name), value);
1014 }
1015 if !op.query_params.is_empty() {
1017 let qs: Vec<String> =
1018 op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1019 url_path = format!("{}?{}", url_path, qs.join("&"));
1020 }
1021
1022 let mut effective_headers = self.effective_headers(&op.header_params);
1024
1025 if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
1027 let code = match feature {
1028 ConformanceFeature::Response400 => "400",
1029 ConformanceFeature::Response404 => "404",
1030 _ => unreachable!(),
1031 };
1032 effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
1033 }
1034
1035 let needs_auth = matches!(
1037 feature,
1038 ConformanceFeature::SecurityBearer
1039 | ConformanceFeature::SecurityBasic
1040 | ConformanceFeature::SecurityApiKey
1041 ) || !op.security_schemes.is_empty();
1042
1043 if needs_auth {
1044 self.inject_security_headers(&op.security_schemes, &mut effective_headers);
1045 }
1046
1047 let method = match op.method.as_str() {
1049 "GET" => Method::GET,
1050 "POST" => Method::POST,
1051 "PUT" => Method::PUT,
1052 "PATCH" => Method::PATCH,
1053 "DELETE" => Method::DELETE,
1054 "HEAD" => Method::HEAD,
1055 "OPTIONS" => Method::OPTIONS,
1056 _ => Method::GET,
1057 };
1058
1059 let body = match method {
1061 Method::POST | Method::PUT | Method::PATCH => {
1062 if let Some(sample) = &op.sample_body {
1063 let content_type =
1065 op.request_body_content_type.as_deref().unwrap_or("application/json");
1066 if !effective_headers
1067 .iter()
1068 .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1069 {
1070 effective_headers
1071 .push(("Content-Type".to_string(), content_type.to_string()));
1072 }
1073 match content_type {
1074 "application/x-www-form-urlencoded" => {
1075 let fields: Vec<(String, String)> = serde_json::from_str::<
1077 serde_json::Value,
1078 >(
1079 sample
1080 )
1081 .ok()
1082 .and_then(|v| {
1083 v.as_object().map(|obj| {
1084 obj.iter()
1085 .map(|(k, v)| {
1086 (k.clone(), v.as_str().unwrap_or("").to_string())
1087 })
1088 .collect()
1089 })
1090 })
1091 .unwrap_or_default();
1092 Some(CheckBody::FormUrlencoded(fields))
1093 }
1094 _ => {
1095 match serde_json::from_str::<serde_json::Value>(sample) {
1097 Ok(v) => Some(CheckBody::Json(v)),
1098 Err(_) => Some(CheckBody::Raw {
1099 content: sample.clone(),
1100 content_type: content_type.to_string(),
1101 }),
1102 }
1103 }
1104 }
1105 } else {
1106 None
1107 }
1108 }
1109 _ => None,
1110 };
1111
1112 let validation = self.determine_validation(feature, op);
1114
1115 ConformanceCheck {
1116 name: check_name.to_string(),
1117 method,
1118 path: url_path,
1119 headers: effective_headers,
1120 body,
1121 validation,
1122 }
1123 }
1124
1125 fn determine_validation(
1127 &self,
1128 feature: &ConformanceFeature,
1129 op: &AnnotatedOperation,
1130 ) -> CheckValidation {
1131 match feature {
1132 ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1133 ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1134 ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1135 ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1136 ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1137 ConformanceFeature::SecurityBearer
1138 | ConformanceFeature::SecurityBasic
1139 | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1140 min: 200,
1141 max_exclusive: 400,
1142 },
1143 ConformanceFeature::ResponseValidation => {
1144 if let Some(schema) = &op.response_schema {
1145 let schema_json = openapi_schema_to_json_schema(schema);
1147 CheckValidation::SchemaValidation {
1148 status_min: 200,
1149 status_max: 500,
1150 schema: schema_json,
1151 }
1152 } else {
1153 CheckValidation::StatusRange {
1154 min: 200,
1155 max_exclusive: 500,
1156 }
1157 }
1158 }
1159 _ => CheckValidation::StatusRange {
1160 min: 200,
1161 max_exclusive: 500,
1162 },
1163 }
1164 }
1165
1166 fn add_ref_get(&mut self, name: &str, path: &str) {
1168 self.checks.push(ConformanceCheck {
1169 name: name.to_string(),
1170 method: Method::GET,
1171 path: path.to_string(),
1172 headers: self.custom_headers_only(),
1173 body: None,
1174 validation: CheckValidation::StatusRange {
1175 min: 200,
1176 max_exclusive: 500,
1177 },
1178 });
1179 }
1180
1181 fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1183 let mut headers = Vec::new();
1184 for (k, v) in spec_headers {
1185 if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1187 continue;
1188 }
1189 headers.push((k.clone(), v.clone()));
1190 }
1191 headers.extend(self.config.custom_headers.clone());
1193 headers
1194 }
1195
1196 fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1198 for (k, v) in &self.config.custom_headers {
1199 if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1200 headers.push((k.clone(), v.clone()));
1201 }
1202 }
1203 headers
1204 }
1205
1206 fn custom_headers_only(&self) -> Vec<(String, String)> {
1208 self.config.custom_headers.clone()
1209 }
1210
1211 fn inject_security_headers(
1215 &self,
1216 schemes: &[SecuritySchemeInfo],
1217 headers: &mut Vec<(String, String)>,
1218 ) {
1219 let has_cookie_auth =
1221 self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1222 let mut to_add: Vec<(String, String)> = Vec::new();
1223
1224 for scheme in schemes {
1225 match scheme {
1226 SecuritySchemeInfo::Bearer => {
1227 if !has_cookie_auth
1228 && !Self::header_present(
1229 "Authorization",
1230 headers,
1231 &self.config.custom_headers,
1232 )
1233 {
1234 to_add.push((
1235 "Authorization".to_string(),
1236 "Bearer mockforge-conformance-test-token".to_string(),
1237 ));
1238 }
1239 }
1240 SecuritySchemeInfo::Basic => {
1241 if !has_cookie_auth
1242 && !Self::header_present(
1243 "Authorization",
1244 headers,
1245 &self.config.custom_headers,
1246 )
1247 {
1248 let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1249 use base64::Engine;
1250 let encoded =
1251 base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1252 to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1253 }
1254 }
1255 SecuritySchemeInfo::ApiKey { location, name } => match location {
1256 ApiKeyLocation::Header => {
1257 if !Self::header_present(name, headers, &self.config.custom_headers) {
1258 let key = self
1259 .config
1260 .api_key
1261 .as_deref()
1262 .unwrap_or("mockforge-conformance-test-key");
1263 to_add.push((name.clone(), key.to_string()));
1264 }
1265 }
1266 ApiKeyLocation::Cookie => {
1267 if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1268 to_add.push((
1269 "Cookie".to_string(),
1270 format!("{}=mockforge-conformance-test-session", name),
1271 ));
1272 }
1273 }
1274 ApiKeyLocation::Query => {
1275 }
1277 },
1278 }
1279 }
1280
1281 headers.extend(to_add);
1282 }
1283
1284 fn header_present(
1286 name: &str,
1287 headers: &[(String, String)],
1288 custom_headers: &[(String, String)],
1289 ) -> bool {
1290 headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1291 || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1292 }
1293
1294 fn add_custom_check(&mut self, check: &CustomCheck) {
1296 let method = match check.method.to_uppercase().as_str() {
1297 "GET" => Method::GET,
1298 "POST" => Method::POST,
1299 "PUT" => Method::PUT,
1300 "PATCH" => Method::PATCH,
1301 "DELETE" => Method::DELETE,
1302 "HEAD" => Method::HEAD,
1303 "OPTIONS" => Method::OPTIONS,
1304 _ => Method::GET,
1305 };
1306
1307 let mut headers: Vec<(String, String)> =
1309 check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1310 for (k, v) in &self.config.custom_headers {
1312 if !check.headers.contains_key(k) {
1313 headers.push((k.clone(), v.clone()));
1314 }
1315 }
1316 if check.body.is_some()
1318 && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1319 {
1320 headers.push(("Content-Type".to_string(), "application/json".to_string()));
1321 }
1322
1323 let body = check
1325 .body
1326 .as_ref()
1327 .and_then(|b| serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json));
1328
1329 let expected_headers: Vec<(String, String)> =
1331 check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1332
1333 let expected_body_fields: Vec<(String, String)> = check
1335 .expected_body_fields
1336 .iter()
1337 .map(|f| (f.name.clone(), f.field_type.clone()))
1338 .collect();
1339
1340 self.checks.push(ConformanceCheck {
1342 name: check.name.clone(),
1343 method,
1344 path: check.path.clone(),
1345 headers,
1346 body,
1347 validation: CheckValidation::Custom {
1348 expected_status: check.expected_status,
1349 expected_headers,
1350 expected_body_fields,
1351 },
1352 });
1353 }
1354}
1355
1356fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1359 use openapiv3::{SchemaKind, Type};
1360
1361 match &schema.schema_kind {
1362 SchemaKind::Type(Type::Object(obj)) => {
1363 let mut props = serde_json::Map::new();
1364 for (name, prop_ref) in &obj.properties {
1365 if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1366 props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1367 }
1368 }
1369 let mut schema_obj = serde_json::json!({
1370 "type": "object",
1371 "properties": props,
1372 });
1373 if !obj.required.is_empty() {
1374 schema_obj["required"] = serde_json::Value::Array(
1375 obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1376 );
1377 }
1378 schema_obj
1379 }
1380 SchemaKind::Type(Type::Array(arr)) => {
1381 let mut schema_obj = serde_json::json!({"type": "array"});
1382 if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1383 schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1384 }
1385 schema_obj
1386 }
1387 SchemaKind::Type(Type::String(s)) => {
1388 let mut obj = serde_json::json!({"type": "string"});
1389 if let Some(min) = s.min_length {
1390 obj["minLength"] = serde_json::json!(min);
1391 }
1392 if let Some(max) = s.max_length {
1393 obj["maxLength"] = serde_json::json!(max);
1394 }
1395 if let Some(pattern) = &s.pattern {
1396 obj["pattern"] = serde_json::json!(pattern);
1397 }
1398 if !s.enumeration.is_empty() {
1399 obj["enum"] = serde_json::Value::Array(
1400 s.enumeration
1401 .iter()
1402 .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1403 .collect(),
1404 );
1405 }
1406 obj
1407 }
1408 SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1409 SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1410 SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1411 _ => serde_json::json!({}),
1412 }
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417 use super::*;
1418
1419 #[test]
1420 fn test_reference_check_count() {
1421 let config = ConformanceConfig {
1422 target_url: "http://localhost:3000".to_string(),
1423 ..Default::default()
1424 };
1425 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1426 assert_eq!(executor.check_count(), 47);
1429 }
1430
1431 #[test]
1432 fn test_reference_checks_with_category_filter() {
1433 let config = ConformanceConfig {
1434 target_url: "http://localhost:3000".to_string(),
1435 categories: Some(vec!["Parameters".to_string()]),
1436 ..Default::default()
1437 };
1438 let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1439 assert_eq!(executor.check_count(), 7);
1440 }
1441
1442 #[test]
1443 fn test_validate_status_range() {
1444 let config = ConformanceConfig {
1445 target_url: "http://localhost:3000".to_string(),
1446 ..Default::default()
1447 };
1448 let executor = NativeConformanceExecutor::new(config).unwrap();
1449 let headers = HashMap::new();
1450
1451 assert!(
1452 executor
1453 .validate_response(
1454 &CheckValidation::StatusRange {
1455 min: 200,
1456 max_exclusive: 500,
1457 },
1458 200,
1459 &headers,
1460 "",
1461 )
1462 .0
1463 );
1464 assert!(
1465 executor
1466 .validate_response(
1467 &CheckValidation::StatusRange {
1468 min: 200,
1469 max_exclusive: 500,
1470 },
1471 404,
1472 &headers,
1473 "",
1474 )
1475 .0
1476 );
1477 assert!(
1478 !executor
1479 .validate_response(
1480 &CheckValidation::StatusRange {
1481 min: 200,
1482 max_exclusive: 500,
1483 },
1484 500,
1485 &headers,
1486 "",
1487 )
1488 .0
1489 );
1490 }
1491
1492 #[test]
1493 fn test_validate_exact_status() {
1494 let config = ConformanceConfig {
1495 target_url: "http://localhost:3000".to_string(),
1496 ..Default::default()
1497 };
1498 let executor = NativeConformanceExecutor::new(config).unwrap();
1499 let headers = HashMap::new();
1500
1501 assert!(
1502 executor
1503 .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
1504 .0
1505 );
1506 assert!(
1507 !executor
1508 .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
1509 .0
1510 );
1511 }
1512
1513 #[test]
1514 fn test_validate_schema() {
1515 let config = ConformanceConfig {
1516 target_url: "http://localhost:3000".to_string(),
1517 ..Default::default()
1518 };
1519 let executor = NativeConformanceExecutor::new(config).unwrap();
1520 let headers = HashMap::new();
1521
1522 let schema = serde_json::json!({
1523 "type": "object",
1524 "properties": {
1525 "name": {"type": "string"},
1526 "age": {"type": "integer"}
1527 },
1528 "required": ["name"]
1529 });
1530
1531 let (passed, violations) = executor.validate_response(
1532 &CheckValidation::SchemaValidation {
1533 status_min: 200,
1534 status_max: 300,
1535 schema: schema.clone(),
1536 },
1537 200,
1538 &headers,
1539 r#"{"name": "test", "age": 25}"#,
1540 );
1541 assert!(passed);
1542 assert!(violations.is_empty());
1543
1544 let (passed, violations) = executor.validate_response(
1546 &CheckValidation::SchemaValidation {
1547 status_min: 200,
1548 status_max: 300,
1549 schema: schema.clone(),
1550 },
1551 200,
1552 &headers,
1553 r#"{"age": 25}"#,
1554 );
1555 assert!(!passed);
1556 assert!(!violations.is_empty());
1557 assert_eq!(violations[0].violation_type, "Required");
1558 }
1559
1560 #[test]
1561 fn test_validate_custom() {
1562 let config = ConformanceConfig {
1563 target_url: "http://localhost:3000".to_string(),
1564 ..Default::default()
1565 };
1566 let executor = NativeConformanceExecutor::new(config).unwrap();
1567 let mut headers = HashMap::new();
1568 headers.insert("content-type".to_string(), "application/json".to_string());
1569
1570 assert!(
1571 executor
1572 .validate_response(
1573 &CheckValidation::Custom {
1574 expected_status: 200,
1575 expected_headers: vec![(
1576 "content-type".to_string(),
1577 "application/json".to_string(),
1578 )],
1579 expected_body_fields: vec![("name".to_string(), "string".to_string())],
1580 },
1581 200,
1582 &headers,
1583 r#"{"name": "test"}"#,
1584 )
1585 .0
1586 );
1587
1588 assert!(
1590 !executor
1591 .validate_response(
1592 &CheckValidation::Custom {
1593 expected_status: 200,
1594 expected_headers: vec![],
1595 expected_body_fields: vec![],
1596 },
1597 404,
1598 &headers,
1599 "",
1600 )
1601 .0
1602 );
1603 }
1604
1605 #[test]
1606 fn test_aggregate_results() {
1607 let results = vec![
1608 CheckResult {
1609 name: "check1".to_string(),
1610 passed: true,
1611 failure_detail: None,
1612 captured: None,
1613 },
1614 CheckResult {
1615 name: "check2".to_string(),
1616 passed: false,
1617 captured: None,
1618 failure_detail: Some(FailureDetail {
1619 check: "check2".to_string(),
1620 request: FailureRequest {
1621 method: "GET".to_string(),
1622 url: "http://example.com".to_string(),
1623 headers: HashMap::new(),
1624 body: String::new(),
1625 },
1626 response: FailureResponse {
1627 status: 500,
1628 headers: HashMap::new(),
1629 body: "error".to_string(),
1630 },
1631 expected: "status >= 200 && status < 500".to_string(),
1632 schema_violations: Vec::new(),
1633 }),
1634 },
1635 ];
1636
1637 let report = NativeConformanceExecutor::aggregate(results);
1638 let raw = report.raw_check_results();
1639 assert_eq!(raw.get("check1"), Some(&(1, 0)));
1640 assert_eq!(raw.get("check2"), Some(&(0, 1)));
1641 }
1642
1643 #[test]
1644 fn test_custom_check_building() {
1645 let config = ConformanceConfig {
1646 target_url: "http://localhost:3000".to_string(),
1647 ..Default::default()
1648 };
1649 let mut executor = NativeConformanceExecutor::new(config).unwrap();
1650
1651 let custom = CustomCheck {
1652 name: "custom:test-get".to_string(),
1653 path: "/api/test".to_string(),
1654 method: "GET".to_string(),
1655 expected_status: 200,
1656 body: None,
1657 expected_headers: std::collections::HashMap::new(),
1658 expected_body_fields: vec![],
1659 headers: std::collections::HashMap::new(),
1660 };
1661
1662 executor.add_custom_check(&custom);
1663 assert_eq!(executor.check_count(), 1);
1664 assert_eq!(executor.checks[0].name, "custom:test-get");
1665 }
1666
1667 #[test]
1668 fn test_openapi_schema_to_json_schema_object() {
1669 use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
1670
1671 let schema = Schema {
1672 schema_data: SchemaData::default(),
1673 schema_kind: SchemaKind::Type(Type::Object(ObjectType {
1674 required: vec!["name".to_string()],
1675 ..Default::default()
1676 })),
1677 };
1678
1679 let json = openapi_schema_to_json_schema(&schema);
1680 assert_eq!(json["type"], "object");
1681 assert_eq!(json["required"][0], "name");
1682 }
1683}