1use crate::error::Error;
2use openapiv3::{OpenAPI, Operation, Parameter, ReferenceOr, RequestBody, SecurityScheme};
3use std::collections::HashMap;
4
5#[derive(Debug, Default)]
7pub struct ValidationResult {
8 pub warnings: Vec<ValidationWarning>,
10 pub errors: Vec<Error>,
12}
13
14impl ValidationResult {
15 #[must_use]
17 pub const fn new() -> Self {
18 Self {
19 warnings: Vec::new(),
20 errors: Vec::new(),
21 }
22 }
23
24 pub fn into_result(self) -> Result<(), Error> {
30 self.errors.into_iter().next().map_or_else(|| Ok(()), Err)
31 }
32
33 #[must_use]
35 pub const fn is_valid(&self) -> bool {
36 self.errors.is_empty()
37 }
38
39 pub fn add_error(&mut self, error: Error) {
41 self.errors.push(error);
42 }
43
44 pub fn add_warning(&mut self, warning: ValidationWarning) {
46 self.warnings.push(warning);
47 }
48}
49
50#[derive(Debug, Clone)]
52pub struct ValidationWarning {
53 pub endpoint: UnsupportedEndpoint,
55 pub reason: String,
57}
58
59impl ValidationWarning {
60 #[must_use]
62 pub fn should_skip_endpoint(&self) -> bool {
63 self.reason.contains("no supported content types")
64 || self.reason.contains("unsupported authentication")
65 }
66
67 #[must_use]
69 pub fn to_skip_endpoint(&self) -> Option<(String, String)> {
70 if self.should_skip_endpoint() {
71 Some((self.endpoint.path.clone(), self.endpoint.method.clone()))
72 } else {
73 None
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct UnsupportedEndpoint {
81 pub path: String,
83 pub method: String,
85 pub content_type: String,
87}
88
89pub struct SpecValidator;
91
92impl SpecValidator {
93 #[must_use]
95 pub const fn new() -> Self {
96 Self
97 }
98
99 fn get_unsupported_content_type_reason(content_type: &str) -> &'static str {
101 match content_type {
102 "multipart/form-data" => "file uploads are not supported",
104 "application/octet-stream" => "binary data uploads are not supported",
105 ct if ct.starts_with("image/") => "image uploads are not supported",
106 "application/pdf" => "PDF uploads are not supported",
107
108 "application/xml" | "text/xml" => "XML content is not supported",
110 "application/x-www-form-urlencoded" => "form-encoded data is not supported",
111 "text/plain" => "plain text content is not supported",
112 "text/csv" => "CSV content is not supported",
113
114 "application/x-ndjson" => "newline-delimited JSON is not supported",
116 "application/graphql" => "GraphQL content is not supported",
117
118 _ => "is not supported",
120 }
121 }
122
123 #[deprecated(
134 since = "0.1.2",
135 note = "Use `validate_with_mode()` instead. This method defaults to strict mode which may not be desired."
136 )]
137 pub fn validate(&self, spec: &OpenAPI) -> Result<(), Error> {
138 self.validate_with_mode(spec, true).into_result()
139 }
140
141 #[must_use]
152 pub fn validate_with_mode(&self, spec: &OpenAPI, strict: bool) -> ValidationResult {
153 let mut result = ValidationResult::new();
154
155 let mut unsupported_schemes = HashMap::new();
157 if let Some(components) = &spec.components {
158 for (name, scheme_ref) in &components.security_schemes {
159 match scheme_ref {
160 ReferenceOr::Item(scheme) => {
161 if let Err(e) = Self::validate_security_scheme(name, scheme) {
162 Self::handle_security_scheme_error(
163 e,
164 strict,
165 name,
166 &mut result,
167 &mut unsupported_schemes,
168 );
169 }
170 }
171 ReferenceOr::Reference { .. } => {
172 result.add_error(Error::Validation(format!(
173 "Security scheme references are not supported: '{name}'"
174 )));
175 }
176 }
177 }
178 }
179
180 for (path, path_item_ref) in spec.paths.iter() {
182 if let ReferenceOr::Item(path_item) = path_item_ref {
183 for (method, operation_opt) in crate::spec::http_methods_iter(path_item) {
184 if let Some(operation) = operation_opt {
185 Self::validate_operation(
186 path,
187 &method.to_lowercase(),
188 operation,
189 &mut result,
190 strict,
191 &unsupported_schemes,
192 spec,
193 );
194 }
195 }
196 }
197 }
198
199 result
200 }
201
202 fn validate_security_scheme(
204 name: &str,
205 scheme: &SecurityScheme,
206 ) -> Result<Option<String>, Error> {
207 let unsupported_reason = match scheme {
209 SecurityScheme::APIKey { .. } => None, SecurityScheme::HTTP {
211 scheme: http_scheme,
212 ..
213 } => {
214 let unsupported_complex_schemes = ["negotiate", "oauth", "oauth2", "openidconnect"];
217 if unsupported_complex_schemes.contains(&http_scheme.to_lowercase().as_str()) {
218 Some(format!(
219 "HTTP scheme '{http_scheme}' requires complex authentication flows"
220 ))
221 } else {
222 None }
224 }
225 SecurityScheme::OAuth2 { .. } => {
226 Some("OAuth2 authentication is not supported".to_string())
227 }
228 SecurityScheme::OpenIDConnect { .. } => {
229 Some("OpenID Connect authentication is not supported".to_string())
230 }
231 };
232
233 if let Some(reason) = unsupported_reason {
235 return Err(Error::Validation(format!(
236 "Security scheme '{name}' uses unsupported authentication: {reason}"
237 )));
238 }
239
240 let (SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. }) =
242 scheme
243 else {
244 return Ok(None);
245 };
246
247 if let Some(aperture_secret) = extensions.get("x-aperture-secret") {
248 let secret_obj = aperture_secret.as_object().ok_or_else(|| {
250 Error::Validation(format!(
251 "Invalid x-aperture-secret in security scheme '{name}': must be an object"
252 ))
253 })?;
254
255 let source = secret_obj
257 .get("source")
258 .ok_or_else(|| {
259 Error::Validation(format!(
260 "Missing 'source' field in x-aperture-secret for security scheme '{name}'"
261 ))
262 })?
263 .as_str()
264 .ok_or_else(|| {
265 Error::Validation(format!(
266 "Invalid 'source' field in x-aperture-secret for security scheme '{name}': must be a string"
267 ))
268 })?;
269
270 if source != "env" {
272 return Err(Error::Validation(format!(
273 "Unsupported source '{source}' in x-aperture-secret for security scheme '{name}'. Only 'env' is supported."
274 )));
275 }
276
277 let env_name = secret_obj
279 .get("name")
280 .ok_or_else(|| {
281 Error::Validation(format!(
282 "Missing 'name' field in x-aperture-secret for security scheme '{name}'"
283 ))
284 })?
285 .as_str()
286 .ok_or_else(|| {
287 Error::Validation(format!(
288 "Invalid 'name' field in x-aperture-secret for security scheme '{name}': must be a string"
289 ))
290 })?;
291
292 if env_name.is_empty() {
294 return Err(Error::Validation(format!(
295 "Empty 'name' field in x-aperture-secret for security scheme '{name}'"
296 )));
297 }
298
299 if !env_name.chars().all(|c| c.is_alphanumeric() || c == '_')
301 || env_name.chars().next().is_some_and(char::is_numeric)
302 {
303 return Err(Error::Validation(format!(
304 "Invalid environment variable name '{env_name}' in x-aperture-secret for security scheme '{name}'. Must contain only alphanumeric characters and underscores, and not start with a digit."
305 )));
306 }
307 }
308
309 Ok(None)
310 }
311
312 fn handle_security_scheme_error(
314 error: Error,
315 strict: bool,
316 scheme_name: &str,
317 result: &mut ValidationResult,
318 unsupported_schemes: &mut HashMap<String, String>,
319 ) {
320 if strict {
321 result.add_error(error);
322 return;
323 }
324
325 match error {
326 Error::Validation(ref msg) if msg.contains("unsupported authentication") => {
327 unsupported_schemes.insert(scheme_name.to_string(), msg.clone());
329 }
330 _ => {
331 result.add_error(error);
333 }
334 }
335 }
336
337 fn should_skip_operation_for_auth(
340 path: &str,
341 method: &str,
342 operation: &Operation,
343 spec: &OpenAPI,
344 strict: bool,
345 unsupported_schemes: &HashMap<String, String>,
346 result: &mut ValidationResult,
347 ) -> bool {
348 if strict || unsupported_schemes.is_empty() {
350 return false;
351 }
352
353 let Some(reqs) = operation.security.as_ref().or(spec.security.as_ref()) else {
355 return false;
356 };
357
358 if reqs.is_empty() {
360 return false;
361 }
362
363 if !Self::should_skip_due_to_auth(reqs, unsupported_schemes) {
365 return false;
366 }
367
368 let scheme_details = Self::format_unsupported_scheme_details(reqs, unsupported_schemes);
370 let reason = Self::format_auth_skip_reason(reqs, &scheme_details);
371
372 result.add_warning(ValidationWarning {
373 endpoint: UnsupportedEndpoint {
374 path: path.to_string(),
375 method: method.to_uppercase(),
376 content_type: String::new(),
377 },
378 reason,
379 });
380
381 true
382 }
383
384 fn format_unsupported_scheme_details(
386 reqs: &[openapiv3::SecurityRequirement],
387 unsupported_schemes: &HashMap<String, String>,
388 ) -> Vec<String> {
389 reqs.iter()
390 .flat_map(|req| req.keys())
391 .filter_map(|scheme_name| {
392 unsupported_schemes.get(scheme_name).map(|msg| {
393 if msg.contains("OAuth2") {
395 format!("{scheme_name} (OAuth2)")
396 } else if msg.contains("OpenID Connect") {
397 format!("{scheme_name} (OpenID Connect)")
398 } else if msg.contains("complex authentication flows") {
399 format!("{scheme_name} (requires complex flow)")
400 } else {
401 scheme_name.clone()
402 }
403 })
404 })
405 .collect()
406 }
407
408 fn format_auth_skip_reason(
410 reqs: &[openapiv3::SecurityRequirement],
411 scheme_details: &[String],
412 ) -> String {
413 if scheme_details.is_empty() {
414 format!(
415 "endpoint requires unsupported authentication schemes: {}",
416 reqs.iter()
417 .flat_map(|req| req.keys())
418 .cloned()
419 .collect::<Vec<_>>()
420 .join(", ")
421 )
422 } else {
423 format!(
424 "endpoint requires unsupported authentication: {}",
425 scheme_details.join(", ")
426 )
427 }
428 }
429
430 fn should_skip_due_to_auth(
432 security_reqs: &[openapiv3::SecurityRequirement],
433 unsupported_schemes: &HashMap<String, String>,
434 ) -> bool {
435 security_reqs.iter().all(|req| {
436 req.keys()
437 .all(|scheme| unsupported_schemes.contains_key(scheme))
438 })
439 }
440
441 fn validate_operation(
443 path: &str,
444 method: &str,
445 operation: &Operation,
446 result: &mut ValidationResult,
447 strict: bool,
448 unsupported_schemes: &HashMap<String, String>,
449 spec: &OpenAPI,
450 ) {
451 if Self::should_skip_operation_for_auth(
453 path,
454 method,
455 operation,
456 spec,
457 strict,
458 unsupported_schemes,
459 result,
460 ) {
461 return;
462 }
463
464 for param_ref in &operation.parameters {
466 match param_ref {
467 ReferenceOr::Item(param) => {
468 if let Err(e) = Self::validate_parameter(path, method, param) {
469 result.add_error(e);
470 }
471 }
472 ReferenceOr::Reference { .. } => {
473 }
475 }
476 }
477
478 if let Some(request_body_ref) = &operation.request_body {
480 match request_body_ref {
481 ReferenceOr::Item(request_body) => {
482 Self::validate_request_body(path, method, request_body, result, strict);
483 }
484 ReferenceOr::Reference { .. } => {
485 result.add_error(Error::Validation(format!(
486 "Request body references are not supported in {method} {path}."
487 )));
488 }
489 }
490 }
491 }
492
493 fn validate_parameter(path: &str, method: &str, param: &Parameter) -> Result<(), Error> {
495 let param_data = match param {
496 Parameter::Query { parameter_data, .. }
497 | Parameter::Header { parameter_data, .. }
498 | Parameter::Path { parameter_data, .. }
499 | Parameter::Cookie { parameter_data, .. } => parameter_data,
500 };
501
502 match ¶m_data.format {
503 openapiv3::ParameterSchemaOrContent::Schema(_) => Ok(()),
504 openapiv3::ParameterSchemaOrContent::Content(_) => {
505 Err(Error::Validation(format!(
506 "Parameter '{}' in {method} {path} uses unsupported content-based serialization. Only schema-based parameters are supported.",
507 param_data.name
508 )))
509 }
510 }
511 }
512
513 fn is_json_content_type(content_type: &str) -> bool {
515 let base_type = content_type
517 .split(';')
518 .next()
519 .unwrap_or(content_type)
520 .trim();
521
522 base_type.eq_ignore_ascii_case("application/json")
524 || base_type.to_lowercase().ends_with("+json")
525 }
526
527 fn validate_request_body(
529 path: &str,
530 method: &str,
531 request_body: &RequestBody,
532 result: &mut ValidationResult,
533 strict: bool,
534 ) {
535 let (has_json, unsupported_types) = Self::categorize_content_types(request_body);
536
537 if unsupported_types.is_empty() {
538 return;
539 }
540
541 if strict {
542 Self::add_strict_mode_errors(path, method, &unsupported_types, result);
543 } else {
544 Self::add_non_strict_warning(path, method, has_json, &unsupported_types, result);
545 }
546 }
547
548 fn categorize_content_types(request_body: &RequestBody) -> (bool, Vec<&String>) {
550 let mut has_json = false;
551 let mut unsupported_types = Vec::new();
552
553 for content_type in request_body.content.keys() {
554 if Self::is_json_content_type(content_type) {
555 has_json = true;
556 } else {
557 unsupported_types.push(content_type);
558 }
559 }
560
561 (has_json, unsupported_types)
562 }
563
564 fn add_strict_mode_errors(
566 path: &str,
567 method: &str,
568 unsupported_types: &[&String],
569 result: &mut ValidationResult,
570 ) {
571 for content_type in unsupported_types {
572 let error = Error::Validation(format!(
573 "Unsupported request body content type '{content_type}' in {method} {path}. Only 'application/json' is supported in v1.0."
574 ));
575 result.add_error(error);
576 }
577 }
578
579 fn add_non_strict_warning(
581 path: &str,
582 method: &str,
583 has_json: bool,
584 unsupported_types: &[&String],
585 result: &mut ValidationResult,
586 ) {
587 let content_types: Vec<String> = unsupported_types
588 .iter()
589 .map(|ct| {
590 let reason = Self::get_unsupported_content_type_reason(ct);
591 format!("{ct} ({reason})")
592 })
593 .collect();
594
595 let reason = if has_json {
596 "endpoint has unsupported content types alongside JSON"
597 } else {
598 "endpoint has no supported content types"
599 };
600
601 let warning = ValidationWarning {
602 endpoint: UnsupportedEndpoint {
603 path: path.to_string(),
604 method: method.to_uppercase(),
605 content_type: content_types.join(", "),
606 },
607 reason: reason.to_string(),
608 };
609
610 result.add_warning(warning);
611 }
612}
613
614impl Default for SpecValidator {
615 fn default() -> Self {
616 Self::new()
617 }
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 use openapiv3::{Components, Info, OpenAPI};
624
625 fn create_test_spec() -> OpenAPI {
626 OpenAPI {
627 openapi: "3.0.0".to_string(),
628 info: Info {
629 title: "Test API".to_string(),
630 version: "1.0.0".to_string(),
631 ..Default::default()
632 },
633 ..Default::default()
634 }
635 }
636
637 #[test]
638 fn test_validate_empty_spec() {
639 let validator = SpecValidator::new();
640 let spec = create_test_spec();
641 assert!(validator
642 .validate_with_mode(&spec, true)
643 .into_result()
644 .is_ok());
645 }
646
647 #[test]
648 fn test_validate_oauth2_scheme_rejected() {
649 let validator = SpecValidator::new();
650 let mut spec = create_test_spec();
651 let mut components = Components::default();
652 components.security_schemes.insert(
653 "oauth".to_string(),
654 ReferenceOr::Item(SecurityScheme::OAuth2 {
655 flows: Default::default(),
656 description: None,
657 extensions: Default::default(),
658 }),
659 );
660 spec.components = Some(components);
661
662 let result = validator.validate_with_mode(&spec, true).into_result();
663 assert!(result.is_err());
664 match result.unwrap_err() {
665 Error::Validation(msg) => {
666 assert!(msg.contains("OAuth2"));
667 assert!(msg.contains("not supported"));
668 }
669 _ => panic!("Expected Validation error"),
670 }
671 }
672
673 #[test]
674 fn test_validate_reference_rejected() {
675 let validator = SpecValidator::new();
676 let mut spec = create_test_spec();
677 let mut components = Components::default();
678 components.security_schemes.insert(
679 "auth".to_string(),
680 ReferenceOr::Reference {
681 reference: "#/components/securitySchemes/BasicAuth".to_string(),
682 },
683 );
684 spec.components = Some(components);
685
686 let result = validator.validate_with_mode(&spec, true).into_result();
687 assert!(result.is_err());
688 match result.unwrap_err() {
689 Error::Validation(msg) => {
690 assert!(msg.contains("references are not supported"));
691 }
692 _ => panic!("Expected Validation error"),
693 }
694 }
695
696 #[test]
697 fn test_validate_supported_schemes() {
698 let validator = SpecValidator::new();
699 let mut spec = create_test_spec();
700 let mut components = Components::default();
701
702 components.security_schemes.insert(
704 "apiKey".to_string(),
705 ReferenceOr::Item(SecurityScheme::APIKey {
706 location: openapiv3::APIKeyLocation::Header,
707 name: "X-API-Key".to_string(),
708 description: None,
709 extensions: Default::default(),
710 }),
711 );
712
713 components.security_schemes.insert(
715 "bearer".to_string(),
716 ReferenceOr::Item(SecurityScheme::HTTP {
717 scheme: "bearer".to_string(),
718 bearer_format: Some("JWT".to_string()),
719 description: None,
720 extensions: Default::default(),
721 }),
722 );
723
724 components.security_schemes.insert(
726 "basic".to_string(),
727 ReferenceOr::Item(SecurityScheme::HTTP {
728 scheme: "basic".to_string(),
729 bearer_format: None,
730 description: None,
731 extensions: Default::default(),
732 }),
733 );
734
735 spec.components = Some(components);
736
737 assert!(validator
738 .validate_with_mode(&spec, true)
739 .into_result()
740 .is_ok());
741 }
742
743 #[test]
744 fn test_validate_with_mode_non_strict_mixed_content() {
745 use openapiv3::{
746 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
747 };
748
749 let validator = SpecValidator::new();
750 let mut spec = create_test_spec();
751
752 let mut request_body = RequestBody::default();
754 request_body
755 .content
756 .insert("multipart/form-data".to_string(), MediaType::default());
757 request_body
758 .content
759 .insert("application/json".to_string(), MediaType::default());
760 request_body.required = true;
761
762 let mut path_item = PathItem::default();
763 path_item.post = Some(Operation {
764 operation_id: Some("uploadFile".to_string()),
765 tags: vec!["files".to_string()],
766 request_body: Some(ReferenceOr::Item(request_body)),
767 responses: Responses::default(),
768 ..Default::default()
769 });
770
771 spec.paths
772 .paths
773 .insert("/upload".to_string(), PathRef::Item(path_item));
774
775 let result = validator.validate_with_mode(&spec, false);
777 assert!(result.is_valid(), "Non-strict mode should be valid");
778 assert_eq!(
779 result.warnings.len(),
780 1,
781 "Should have one warning for mixed content types"
782 );
783 assert_eq!(result.errors.len(), 0, "Should have no errors");
784
785 let warning = &result.warnings[0];
787 assert_eq!(warning.endpoint.path, "/upload");
788 assert_eq!(warning.endpoint.method, "POST");
789 assert!(warning
790 .endpoint
791 .content_type
792 .contains("multipart/form-data"));
793 assert!(warning
794 .reason
795 .contains("unsupported content types alongside JSON"));
796 }
797
798 #[test]
799 fn test_validate_with_mode_non_strict_only_unsupported() {
800 use openapiv3::{
801 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
802 };
803
804 let validator = SpecValidator::new();
805 let mut spec = create_test_spec();
806
807 let mut request_body = RequestBody::default();
809 request_body
810 .content
811 .insert("multipart/form-data".to_string(), MediaType::default());
812 request_body.required = true;
813
814 let mut path_item = PathItem::default();
815 path_item.post = Some(Operation {
816 operation_id: Some("uploadFile".to_string()),
817 tags: vec!["files".to_string()],
818 request_body: Some(ReferenceOr::Item(request_body)),
819 responses: Responses::default(),
820 ..Default::default()
821 });
822
823 spec.paths
824 .paths
825 .insert("/upload".to_string(), PathRef::Item(path_item));
826
827 let result = validator.validate_with_mode(&spec, false);
829 assert!(result.is_valid(), "Non-strict mode should be valid");
830 assert_eq!(result.warnings.len(), 1, "Should have one warning");
831 assert_eq!(result.errors.len(), 0, "Should have no errors");
832
833 let warning = &result.warnings[0];
834 assert_eq!(warning.endpoint.path, "/upload");
835 assert_eq!(warning.endpoint.method, "POST");
836 assert!(warning
837 .endpoint
838 .content_type
839 .contains("multipart/form-data"));
840 assert!(warning.reason.contains("no supported content types"));
841 }
842
843 #[test]
844 fn test_validate_with_mode_strict() {
845 use openapiv3::{
846 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
847 };
848
849 let validator = SpecValidator::new();
850 let mut spec = create_test_spec();
851
852 let mut request_body = RequestBody::default();
853 request_body
854 .content
855 .insert("multipart/form-data".to_string(), MediaType::default());
856 request_body.required = true;
857
858 let mut path_item = PathItem::default();
859 path_item.post = Some(Operation {
860 operation_id: Some("uploadFile".to_string()),
861 tags: vec!["files".to_string()],
862 request_body: Some(ReferenceOr::Item(request_body)),
863 responses: Responses::default(),
864 ..Default::default()
865 });
866
867 spec.paths
868 .paths
869 .insert("/upload".to_string(), PathRef::Item(path_item));
870
871 let result = validator.validate_with_mode(&spec, true);
873 assert!(!result.is_valid(), "Strict mode should be invalid");
874 assert_eq!(result.warnings.len(), 0, "Should have no warnings");
875 assert_eq!(result.errors.len(), 1, "Should have one error");
876
877 match &result.errors[0] {
878 Error::Validation(msg) => {
879 assert!(msg.contains("multipart/form-data"));
880 assert!(msg.contains("v1.0"));
881 }
882 _ => panic!("Expected Validation error"),
883 }
884 }
885
886 #[test]
887 fn test_validate_with_mode_multiple_content_types() {
888 use openapiv3::{
889 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
890 };
891
892 let validator = SpecValidator::new();
893 let mut spec = create_test_spec();
894
895 let mut path_item1 = PathItem::default();
897 let mut request_body1 = RequestBody::default();
898 request_body1
899 .content
900 .insert("application/xml".to_string(), MediaType::default());
901 path_item1.post = Some(Operation {
902 operation_id: Some("postXml".to_string()),
903 tags: vec!["data".to_string()],
904 request_body: Some(ReferenceOr::Item(request_body1)),
905 responses: Responses::default(),
906 ..Default::default()
907 });
908 spec.paths
909 .paths
910 .insert("/xml".to_string(), PathRef::Item(path_item1));
911
912 let mut path_item2 = PathItem::default();
913 let mut request_body2 = RequestBody::default();
914 request_body2
915 .content
916 .insert("text/plain".to_string(), MediaType::default());
917 path_item2.put = Some(Operation {
918 operation_id: Some("putText".to_string()),
919 tags: vec!["data".to_string()],
920 request_body: Some(ReferenceOr::Item(request_body2)),
921 responses: Responses::default(),
922 ..Default::default()
923 });
924 spec.paths
925 .paths
926 .insert("/text".to_string(), PathRef::Item(path_item2));
927
928 let result = validator.validate_with_mode(&spec, false);
930 assert!(result.is_valid());
931 assert_eq!(result.warnings.len(), 2);
932
933 let warning_paths: Vec<&str> = result
934 .warnings
935 .iter()
936 .map(|w| w.endpoint.path.as_str())
937 .collect();
938 assert!(warning_paths.contains(&"/xml"));
939 assert!(warning_paths.contains(&"/text"));
940 }
941
942 #[test]
943 fn test_validate_with_mode_multiple_unsupported_types_single_endpoint() {
944 use openapiv3::{
945 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
946 };
947
948 let validator = SpecValidator::new();
949 let mut spec = create_test_spec();
950
951 let mut request_body = RequestBody::default();
953 request_body
954 .content
955 .insert("multipart/form-data".to_string(), MediaType::default());
956 request_body
957 .content
958 .insert("application/xml".to_string(), MediaType::default());
959 request_body
960 .content
961 .insert("text/plain".to_string(), MediaType::default());
962 request_body.required = true;
963
964 let mut path_item = PathItem::default();
965 path_item.post = Some(Operation {
966 operation_id: Some("uploadData".to_string()),
967 tags: vec!["data".to_string()],
968 request_body: Some(ReferenceOr::Item(request_body)),
969 responses: Responses::default(),
970 ..Default::default()
971 });
972
973 spec.paths
974 .paths
975 .insert("/data".to_string(), PathRef::Item(path_item));
976
977 let result = validator.validate_with_mode(&spec, false);
979 assert!(result.is_valid(), "Non-strict mode should be valid");
980 assert_eq!(result.warnings.len(), 1, "Should have exactly one warning");
981 assert_eq!(result.errors.len(), 0, "Should have no errors");
982
983 let warning = &result.warnings[0];
984 assert_eq!(warning.endpoint.path, "/data");
985 assert_eq!(warning.endpoint.method, "POST");
986 assert!(warning
988 .endpoint
989 .content_type
990 .contains("multipart/form-data"));
991 assert!(warning.endpoint.content_type.contains("application/xml"));
992 assert!(warning.endpoint.content_type.contains("text/plain"));
993 assert!(warning.reason.contains("no supported content types"));
994 }
995
996 #[test]
997 fn test_validate_unsupported_http_scheme() {
998 let validator = SpecValidator::new();
999 let mut spec = create_test_spec();
1000 let mut components = Components::default();
1001
1002 components.security_schemes.insert(
1004 "negotiate".to_string(),
1005 ReferenceOr::Item(SecurityScheme::HTTP {
1006 scheme: "negotiate".to_string(),
1007 bearer_format: None,
1008 description: None,
1009 extensions: Default::default(),
1010 }),
1011 );
1012
1013 spec.components = Some(components);
1014
1015 let result = validator.validate_with_mode(&spec, true).into_result();
1016 assert!(result.is_err());
1017 match result.unwrap_err() {
1018 Error::Validation(msg) => {
1019 assert!(msg.contains("requires complex authentication flows"));
1020 }
1021 _ => panic!("Expected Validation error"),
1022 }
1023 }
1024
1025 #[test]
1026 fn test_validate_custom_http_schemes_allowed() {
1027 let validator = SpecValidator::new();
1028 let mut spec = create_test_spec();
1029 let mut components = Components::default();
1030
1031 let custom_schemes = vec!["digest", "token", "apikey", "dsn", "custom-auth"];
1033
1034 for scheme in custom_schemes {
1035 components.security_schemes.insert(
1036 format!("{}_auth", scheme),
1037 ReferenceOr::Item(SecurityScheme::HTTP {
1038 scheme: scheme.to_string(),
1039 bearer_format: None,
1040 description: None,
1041 extensions: Default::default(),
1042 }),
1043 );
1044 }
1045
1046 spec.components = Some(components);
1047
1048 let result = validator.validate_with_mode(&spec, true);
1050 assert!(result.is_valid(), "Custom HTTP schemes should be allowed");
1051 }
1052
1053 #[test]
1054 fn test_validate_parameter_reference_allowed() {
1055 use openapiv3::{Operation, PathItem, ReferenceOr as PathRef, Responses};
1056
1057 let validator = SpecValidator::new();
1058 let mut spec = create_test_spec();
1059
1060 let mut path_item = PathItem::default();
1061 path_item.get = Some(Operation {
1062 parameters: vec![ReferenceOr::Reference {
1063 reference: "#/components/parameters/UserId".to_string(),
1064 }],
1065 responses: Responses::default(),
1066 ..Default::default()
1067 });
1068
1069 spec.paths
1070 .paths
1071 .insert("/users/{id}".to_string(), PathRef::Item(path_item));
1072
1073 let result = validator.validate_with_mode(&spec, true).into_result();
1075 assert!(result.is_ok());
1076 }
1077
1078 #[test]
1079 fn test_validate_request_body_non_json_rejected() {
1080 use openapiv3::{
1081 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
1082 };
1083
1084 let validator = SpecValidator::new();
1085 let mut spec = create_test_spec();
1086
1087 let mut request_body = RequestBody::default();
1088 request_body
1089 .content
1090 .insert("application/xml".to_string(), MediaType::default());
1091 request_body.required = true;
1092
1093 let mut path_item = PathItem::default();
1094 path_item.post = Some(Operation {
1095 request_body: Some(ReferenceOr::Item(request_body)),
1096 responses: Responses::default(),
1097 ..Default::default()
1098 });
1099
1100 spec.paths
1101 .paths
1102 .insert("/users".to_string(), PathRef::Item(path_item));
1103
1104 let result = validator.validate_with_mode(&spec, true).into_result();
1105 assert!(result.is_err());
1106 match result.unwrap_err() {
1107 Error::Validation(msg) => {
1108 assert!(msg.contains("Unsupported request body content type 'application/xml'"));
1109 }
1110 _ => panic!("Expected Validation error"),
1111 }
1112 }
1113
1114 #[test]
1115 fn test_validate_x_aperture_secret_valid() {
1116 let validator = SpecValidator::new();
1117 let mut spec = create_test_spec();
1118 let mut components = Components::default();
1119
1120 let mut extensions = serde_json::Map::new();
1122 extensions.insert(
1123 "x-aperture-secret".to_string(),
1124 serde_json::json!({
1125 "source": "env",
1126 "name": "API_TOKEN"
1127 }),
1128 );
1129
1130 components.security_schemes.insert(
1131 "bearerAuth".to_string(),
1132 ReferenceOr::Item(SecurityScheme::HTTP {
1133 scheme: "bearer".to_string(),
1134 bearer_format: None,
1135 description: None,
1136 extensions: extensions.into_iter().collect(),
1137 }),
1138 );
1139 spec.components = Some(components);
1140
1141 assert!(validator
1142 .validate_with_mode(&spec, true)
1143 .into_result()
1144 .is_ok());
1145 }
1146
1147 #[test]
1148 fn test_validate_x_aperture_secret_missing_source() {
1149 let validator = SpecValidator::new();
1150 let mut spec = create_test_spec();
1151 let mut components = Components::default();
1152
1153 let mut extensions = serde_json::Map::new();
1155 extensions.insert(
1156 "x-aperture-secret".to_string(),
1157 serde_json::json!({
1158 "name": "API_TOKEN"
1159 }),
1160 );
1161
1162 components.security_schemes.insert(
1163 "bearerAuth".to_string(),
1164 ReferenceOr::Item(SecurityScheme::HTTP {
1165 scheme: "bearer".to_string(),
1166 bearer_format: None,
1167 description: None,
1168 extensions: extensions.into_iter().collect(),
1169 }),
1170 );
1171 spec.components = Some(components);
1172
1173 let result = validator.validate_with_mode(&spec, true).into_result();
1174 assert!(result.is_err());
1175 match result.unwrap_err() {
1176 Error::Validation(msg) => {
1177 assert!(msg.contains("Missing 'source' field"));
1178 }
1179 _ => panic!("Expected Validation error"),
1180 }
1181 }
1182
1183 #[test]
1184 fn test_validate_x_aperture_secret_missing_name() {
1185 let validator = SpecValidator::new();
1186 let mut spec = create_test_spec();
1187 let mut components = Components::default();
1188
1189 let mut extensions = serde_json::Map::new();
1191 extensions.insert(
1192 "x-aperture-secret".to_string(),
1193 serde_json::json!({
1194 "source": "env"
1195 }),
1196 );
1197
1198 components.security_schemes.insert(
1199 "bearerAuth".to_string(),
1200 ReferenceOr::Item(SecurityScheme::HTTP {
1201 scheme: "bearer".to_string(),
1202 bearer_format: None,
1203 description: None,
1204 extensions: extensions.into_iter().collect(),
1205 }),
1206 );
1207 spec.components = Some(components);
1208
1209 let result = validator.validate_with_mode(&spec, true).into_result();
1210 assert!(result.is_err());
1211 match result.unwrap_err() {
1212 Error::Validation(msg) => {
1213 assert!(msg.contains("Missing 'name' field"));
1214 }
1215 _ => panic!("Expected Validation error"),
1216 }
1217 }
1218
1219 #[test]
1220 fn test_validate_x_aperture_secret_invalid_env_name() {
1221 let validator = SpecValidator::new();
1222 let mut spec = create_test_spec();
1223 let mut components = Components::default();
1224
1225 let mut extensions = serde_json::Map::new();
1227 extensions.insert(
1228 "x-aperture-secret".to_string(),
1229 serde_json::json!({
1230 "source": "env",
1231 "name": "123_INVALID" }),
1233 );
1234
1235 components.security_schemes.insert(
1236 "bearerAuth".to_string(),
1237 ReferenceOr::Item(SecurityScheme::HTTP {
1238 scheme: "bearer".to_string(),
1239 bearer_format: None,
1240 description: None,
1241 extensions: extensions.into_iter().collect(),
1242 }),
1243 );
1244 spec.components = Some(components);
1245
1246 let result = validator.validate_with_mode(&spec, true).into_result();
1247 assert!(result.is_err());
1248 match result.unwrap_err() {
1249 Error::Validation(msg) => {
1250 assert!(msg.contains("Invalid environment variable name"));
1251 }
1252 _ => panic!("Expected Validation error"),
1253 }
1254 }
1255
1256 #[test]
1257 fn test_validate_x_aperture_secret_unsupported_source() {
1258 let validator = SpecValidator::new();
1259 let mut spec = create_test_spec();
1260 let mut components = Components::default();
1261
1262 let mut extensions = serde_json::Map::new();
1264 extensions.insert(
1265 "x-aperture-secret".to_string(),
1266 serde_json::json!({
1267 "source": "file", "name": "API_TOKEN"
1269 }),
1270 );
1271
1272 components.security_schemes.insert(
1273 "bearerAuth".to_string(),
1274 ReferenceOr::Item(SecurityScheme::HTTP {
1275 scheme: "bearer".to_string(),
1276 bearer_format: None,
1277 description: None,
1278 extensions: extensions.into_iter().collect(),
1279 }),
1280 );
1281 spec.components = Some(components);
1282
1283 let result = validator.validate_with_mode(&spec, true).into_result();
1284 assert!(result.is_err());
1285 match result.unwrap_err() {
1286 Error::Validation(msg) => {
1287 assert!(msg.contains("Unsupported source 'file'"));
1288 }
1289 _ => panic!("Expected Validation error"),
1290 }
1291 }
1292}