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