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