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::{Components, Info, OpenAPI};
634
635 fn create_test_spec() -> OpenAPI {
636 OpenAPI {
637 openapi: "3.0.0".to_string(),
638 info: Info {
639 title: "Test API".to_string(),
640 version: "1.0.0".to_string(),
641 ..Default::default()
642 },
643 ..Default::default()
644 }
645 }
646
647 #[test]
648 fn test_validate_empty_spec() {
649 let validator = SpecValidator::new();
650 let spec = create_test_spec();
651 assert!(validator
652 .validate_with_mode(&spec, true)
653 .into_result()
654 .is_ok());
655 }
656
657 #[test]
658 fn test_validate_oauth2_scheme_rejected() {
659 let validator = SpecValidator::new();
660 let mut spec = create_test_spec();
661 let mut components = Components::default();
662 components.security_schemes.insert(
663 "oauth".to_string(),
664 ReferenceOr::Item(SecurityScheme::OAuth2 {
665 flows: Default::default(),
666 description: None,
667 extensions: Default::default(),
668 }),
669 );
670 spec.components = Some(components);
671
672 let result = validator.validate_with_mode(&spec, true).into_result();
673 assert!(result.is_err());
674 match result.unwrap_err() {
675 Error::Internal {
676 kind: ErrorKind::Validation,
677 message,
678 ..
679 } => {
680 assert!(message.contains("OAuth2"));
681 assert!(message.contains("not supported"));
682 }
683 _ => panic!("Expected Validation error"),
684 }
685 }
686
687 #[test]
688 fn test_validate_reference_rejected() {
689 let validator = SpecValidator::new();
690 let mut spec = create_test_spec();
691 let mut components = Components::default();
692 components.security_schemes.insert(
693 "auth".to_string(),
694 ReferenceOr::Reference {
695 reference: "#/components/securitySchemes/BasicAuth".to_string(),
696 },
697 );
698 spec.components = Some(components);
699
700 let result = validator.validate_with_mode(&spec, true).into_result();
701 assert!(result.is_err());
702 match result.unwrap_err() {
703 Error::Internal {
704 kind: ErrorKind::Validation,
705 message: msg,
706 ..
707 } => {
708 assert!(msg.contains("references are not supported"));
709 }
710 _ => panic!("Expected Validation error"),
711 }
712 }
713
714 #[test]
715 fn test_validate_supported_schemes() {
716 let validator = SpecValidator::new();
717 let mut spec = create_test_spec();
718 let mut components = Components::default();
719
720 components.security_schemes.insert(
722 constants::AUTH_SCHEME_APIKEY.to_string(),
723 ReferenceOr::Item(SecurityScheme::APIKey {
724 location: openapiv3::APIKeyLocation::Header,
725 name: "X-API-Key".to_string(),
726 description: None,
727 extensions: Default::default(),
728 }),
729 );
730
731 components.security_schemes.insert(
733 constants::AUTH_SCHEME_BEARER.to_string(),
734 ReferenceOr::Item(SecurityScheme::HTTP {
735 scheme: constants::AUTH_SCHEME_BEARER.to_string(),
736 bearer_format: Some("JWT".to_string()),
737 description: None,
738 extensions: Default::default(),
739 }),
740 );
741
742 components.security_schemes.insert(
744 constants::AUTH_SCHEME_BASIC.to_string(),
745 ReferenceOr::Item(SecurityScheme::HTTP {
746 scheme: constants::AUTH_SCHEME_BASIC.to_string(),
747 bearer_format: None,
748 description: None,
749 extensions: Default::default(),
750 }),
751 );
752
753 spec.components = Some(components);
754
755 assert!(validator
756 .validate_with_mode(&spec, true)
757 .into_result()
758 .is_ok());
759 }
760
761 #[test]
762 fn test_validate_with_mode_non_strict_mixed_content() {
763 use openapiv3::{
764 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
765 };
766
767 let validator = SpecValidator::new();
768 let mut spec = create_test_spec();
769
770 let mut request_body = RequestBody::default();
772 request_body
773 .content
774 .insert("multipart/form-data".to_string(), MediaType::default());
775 request_body.content.insert(
776 constants::CONTENT_TYPE_JSON.to_string(),
777 MediaType::default(),
778 );
779 request_body.required = true;
780
781 let mut path_item = PathItem::default();
782 path_item.post = Some(Operation {
783 operation_id: Some("uploadFile".to_string()),
784 tags: vec!["files".to_string()],
785 request_body: Some(ReferenceOr::Item(request_body)),
786 responses: Responses::default(),
787 ..Default::default()
788 });
789
790 spec.paths
791 .paths
792 .insert("/upload".to_string(), PathRef::Item(path_item));
793
794 let result = validator.validate_with_mode(&spec, false);
796 assert!(result.is_valid(), "Non-strict mode should be valid");
797 assert_eq!(
798 result.warnings.len(),
799 1,
800 "Should have one warning for mixed content types"
801 );
802 assert_eq!(result.errors.len(), 0, "Should have no errors");
803
804 let warning = &result.warnings[0];
806 assert_eq!(warning.endpoint.path, "/upload");
807 assert_eq!(warning.endpoint.method, constants::HTTP_METHOD_POST);
808 assert!(warning
809 .endpoint
810 .content_type
811 .contains("multipart/form-data"));
812 assert!(warning
813 .reason
814 .contains("unsupported content types alongside JSON"));
815 }
816
817 #[test]
818 fn test_validate_with_mode_non_strict_only_unsupported() {
819 use openapiv3::{
820 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
821 };
822
823 let validator = SpecValidator::new();
824 let mut spec = create_test_spec();
825
826 let mut request_body = RequestBody::default();
828 request_body
829 .content
830 .insert("multipart/form-data".to_string(), MediaType::default());
831 request_body.required = true;
832
833 let mut path_item = PathItem::default();
834 path_item.post = Some(Operation {
835 operation_id: Some("uploadFile".to_string()),
836 tags: vec!["files".to_string()],
837 request_body: Some(ReferenceOr::Item(request_body)),
838 responses: Responses::default(),
839 ..Default::default()
840 });
841
842 spec.paths
843 .paths
844 .insert("/upload".to_string(), PathRef::Item(path_item));
845
846 let result = validator.validate_with_mode(&spec, false);
848 assert!(result.is_valid(), "Non-strict mode should be valid");
849 assert_eq!(result.warnings.len(), 1, "Should have one warning");
850 assert_eq!(result.errors.len(), 0, "Should have no errors");
851
852 let warning = &result.warnings[0];
853 assert_eq!(warning.endpoint.path, "/upload");
854 assert_eq!(warning.endpoint.method, constants::HTTP_METHOD_POST);
855 assert!(warning
856 .endpoint
857 .content_type
858 .contains("multipart/form-data"));
859 assert!(warning.reason.contains("no supported content types"));
860 }
861
862 #[test]
863 fn test_validate_with_mode_strict() {
864 use openapiv3::{
865 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
866 };
867
868 let validator = SpecValidator::new();
869 let mut spec = create_test_spec();
870
871 let mut request_body = RequestBody::default();
872 request_body
873 .content
874 .insert("multipart/form-data".to_string(), MediaType::default());
875 request_body.required = true;
876
877 let mut path_item = PathItem::default();
878 path_item.post = Some(Operation {
879 operation_id: Some("uploadFile".to_string()),
880 tags: vec!["files".to_string()],
881 request_body: Some(ReferenceOr::Item(request_body)),
882 responses: Responses::default(),
883 ..Default::default()
884 });
885
886 spec.paths
887 .paths
888 .insert("/upload".to_string(), PathRef::Item(path_item));
889
890 let result = validator.validate_with_mode(&spec, true);
892 assert!(!result.is_valid(), "Strict mode should be invalid");
893 assert_eq!(result.warnings.len(), 0, "Should have no warnings");
894 assert_eq!(result.errors.len(), 1, "Should have one error");
895
896 match &result.errors[0] {
897 Error::Internal {
898 kind: ErrorKind::Validation,
899 message: msg,
900 ..
901 } => {
902 assert!(msg.contains("multipart/form-data"));
903 assert!(msg.contains("v1.0"));
904 }
905 _ => panic!("Expected Validation error"),
906 }
907 }
908
909 #[test]
910 fn test_validate_with_mode_multiple_content_types() {
911 use openapiv3::{
912 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
913 };
914
915 let validator = SpecValidator::new();
916 let mut spec = create_test_spec();
917
918 let mut path_item1 = PathItem::default();
920 let mut request_body1 = RequestBody::default();
921 request_body1.content.insert(
922 constants::CONTENT_TYPE_XML.to_string(),
923 MediaType::default(),
924 );
925 path_item1.post = Some(Operation {
926 operation_id: Some("postXml".to_string()),
927 tags: vec!["data".to_string()],
928 request_body: Some(ReferenceOr::Item(request_body1)),
929 responses: Responses::default(),
930 ..Default::default()
931 });
932 spec.paths
933 .paths
934 .insert("/xml".to_string(), PathRef::Item(path_item1));
935
936 let mut path_item2 = PathItem::default();
937 let mut request_body2 = RequestBody::default();
938 request_body2.content.insert(
939 constants::CONTENT_TYPE_TEXT.to_string(),
940 MediaType::default(),
941 );
942 path_item2.put = Some(Operation {
943 operation_id: Some("putText".to_string()),
944 tags: vec!["data".to_string()],
945 request_body: Some(ReferenceOr::Item(request_body2)),
946 responses: Responses::default(),
947 ..Default::default()
948 });
949 spec.paths
950 .paths
951 .insert("/text".to_string(), PathRef::Item(path_item2));
952
953 let result = validator.validate_with_mode(&spec, false);
955 assert!(result.is_valid());
956 assert_eq!(result.warnings.len(), 2);
957
958 let warning_paths: Vec<&str> = result
959 .warnings
960 .iter()
961 .map(|w| w.endpoint.path.as_str())
962 .collect();
963 assert!(warning_paths.contains(&"/xml"));
964 assert!(warning_paths.contains(&"/text"));
965 }
966
967 #[test]
968 fn test_validate_with_mode_multiple_unsupported_types_single_endpoint() {
969 use openapiv3::{
970 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
971 };
972
973 let validator = SpecValidator::new();
974 let mut spec = create_test_spec();
975
976 let mut request_body = RequestBody::default();
978 request_body
979 .content
980 .insert("multipart/form-data".to_string(), MediaType::default());
981 request_body.content.insert(
982 constants::CONTENT_TYPE_XML.to_string(),
983 MediaType::default(),
984 );
985 request_body.content.insert(
986 constants::CONTENT_TYPE_TEXT.to_string(),
987 MediaType::default(),
988 );
989 request_body.required = true;
990
991 let mut path_item = PathItem::default();
992 path_item.post = Some(Operation {
993 operation_id: Some("uploadData".to_string()),
994 tags: vec!["data".to_string()],
995 request_body: Some(ReferenceOr::Item(request_body)),
996 responses: Responses::default(),
997 ..Default::default()
998 });
999
1000 spec.paths
1001 .paths
1002 .insert("/data".to_string(), PathRef::Item(path_item));
1003
1004 let result = validator.validate_with_mode(&spec, false);
1006 assert!(result.is_valid(), "Non-strict mode should be valid");
1007 assert_eq!(result.warnings.len(), 1, "Should have exactly one warning");
1008 assert_eq!(result.errors.len(), 0, "Should have no errors");
1009
1010 let warning = &result.warnings[0];
1011 assert_eq!(warning.endpoint.path, "/data");
1012 assert_eq!(warning.endpoint.method, constants::HTTP_METHOD_POST);
1013 assert!(warning
1015 .endpoint
1016 .content_type
1017 .contains("multipart/form-data"));
1018 assert!(warning
1019 .endpoint
1020 .content_type
1021 .contains(constants::CONTENT_TYPE_XML));
1022 assert!(warning
1023 .endpoint
1024 .content_type
1025 .contains(constants::CONTENT_TYPE_TEXT));
1026 assert!(warning.reason.contains("no supported content types"));
1027 }
1028
1029 #[test]
1030 fn test_validate_unsupported_http_scheme() {
1031 let validator = SpecValidator::new();
1032 let mut spec = create_test_spec();
1033 let mut components = Components::default();
1034
1035 components.security_schemes.insert(
1037 "negotiate".to_string(),
1038 ReferenceOr::Item(SecurityScheme::HTTP {
1039 scheme: "negotiate".to_string(),
1040 bearer_format: None,
1041 description: None,
1042 extensions: Default::default(),
1043 }),
1044 );
1045
1046 spec.components = Some(components);
1047
1048 let result = validator.validate_with_mode(&spec, true).into_result();
1049 assert!(result.is_err());
1050 match result.unwrap_err() {
1051 Error::Internal {
1052 kind: ErrorKind::Validation,
1053 message: msg,
1054 ..
1055 } => {
1056 assert!(msg.contains("requires complex authentication flows"));
1057 }
1058 _ => panic!("Expected Validation error"),
1059 }
1060 }
1061
1062 #[test]
1063 fn test_validate_custom_http_schemes_allowed() {
1064 let validator = SpecValidator::new();
1065 let mut spec = create_test_spec();
1066 let mut components = Components::default();
1067
1068 let custom_schemes = vec!["digest", "token", "apikey", "dsn", "custom-auth"];
1070
1071 for scheme in custom_schemes {
1072 components.security_schemes.insert(
1073 format!("{}_auth", scheme),
1074 ReferenceOr::Item(SecurityScheme::HTTP {
1075 scheme: scheme.to_string(),
1076 bearer_format: None,
1077 description: None,
1078 extensions: Default::default(),
1079 }),
1080 );
1081 }
1082
1083 spec.components = Some(components);
1084
1085 let result = validator.validate_with_mode(&spec, true);
1087 assert!(result.is_valid(), "Custom HTTP schemes should be allowed");
1088 }
1089
1090 #[test]
1091 fn test_validate_parameter_reference_allowed() {
1092 use openapiv3::{Operation, PathItem, ReferenceOr as PathRef, Responses};
1093
1094 let validator = SpecValidator::new();
1095 let mut spec = create_test_spec();
1096
1097 let mut path_item = PathItem::default();
1098 path_item.get = Some(Operation {
1099 parameters: vec![ReferenceOr::Reference {
1100 reference: "#/components/parameters/UserId".to_string(),
1101 }],
1102 responses: Responses::default(),
1103 ..Default::default()
1104 });
1105
1106 spec.paths
1107 .paths
1108 .insert("/users/{id}".to_string(), PathRef::Item(path_item));
1109
1110 let result = validator.validate_with_mode(&spec, true).into_result();
1112 assert!(result.is_ok());
1113 }
1114
1115 #[test]
1116 fn test_validate_request_body_non_json_rejected() {
1117 use openapiv3::{
1118 MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
1119 };
1120
1121 let validator = SpecValidator::new();
1122 let mut spec = create_test_spec();
1123
1124 let mut request_body = RequestBody::default();
1125 request_body.content.insert(
1126 constants::CONTENT_TYPE_XML.to_string(),
1127 MediaType::default(),
1128 );
1129 request_body.required = true;
1130
1131 let mut path_item = PathItem::default();
1132 path_item.post = Some(Operation {
1133 request_body: Some(ReferenceOr::Item(request_body)),
1134 responses: Responses::default(),
1135 ..Default::default()
1136 });
1137
1138 spec.paths
1139 .paths
1140 .insert("/users".to_string(), PathRef::Item(path_item));
1141
1142 let result = validator.validate_with_mode(&spec, true).into_result();
1143 assert!(result.is_err());
1144 match result.unwrap_err() {
1145 Error::Internal {
1146 kind: ErrorKind::Validation,
1147 message: msg,
1148 ..
1149 } => {
1150 assert!(msg.contains("Unsupported request body content type 'application/xml'"));
1151 }
1152 _ => panic!("Expected Validation error"),
1153 }
1154 }
1155
1156 #[test]
1157 fn test_validate_x_aperture_secret_valid() {
1158 let validator = SpecValidator::new();
1159 let mut spec = create_test_spec();
1160 let mut components = Components::default();
1161
1162 let mut extensions = serde_json::Map::new();
1164 extensions.insert(
1165 crate::constants::EXT_APERTURE_SECRET.to_string(),
1166 serde_json::json!({
1167 "source": "env",
1168 "name": "API_TOKEN"
1169 }),
1170 );
1171
1172 components.security_schemes.insert(
1173 "bearerAuth".to_string(),
1174 ReferenceOr::Item(SecurityScheme::HTTP {
1175 scheme: constants::AUTH_SCHEME_BEARER.to_string(),
1176 bearer_format: None,
1177 description: None,
1178 extensions: extensions.into_iter().collect(),
1179 }),
1180 );
1181 spec.components = Some(components);
1182
1183 assert!(validator
1184 .validate_with_mode(&spec, true)
1185 .into_result()
1186 .is_ok());
1187 }
1188
1189 #[test]
1190 fn test_validate_x_aperture_secret_missing_source() {
1191 let validator = SpecValidator::new();
1192 let mut spec = create_test_spec();
1193 let mut components = Components::default();
1194
1195 let mut extensions = serde_json::Map::new();
1197 extensions.insert(
1198 crate::constants::EXT_APERTURE_SECRET.to_string(),
1199 serde_json::json!({
1200 "name": "API_TOKEN"
1201 }),
1202 );
1203
1204 components.security_schemes.insert(
1205 "bearerAuth".to_string(),
1206 ReferenceOr::Item(SecurityScheme::HTTP {
1207 scheme: constants::AUTH_SCHEME_BEARER.to_string(),
1208 bearer_format: None,
1209 description: None,
1210 extensions: extensions.into_iter().collect(),
1211 }),
1212 );
1213 spec.components = Some(components);
1214
1215 let result = validator.validate_with_mode(&spec, true).into_result();
1216 assert!(result.is_err());
1217 match result.unwrap_err() {
1218 Error::Internal {
1219 kind: ErrorKind::Validation,
1220 message: msg,
1221 ..
1222 } => {
1223 assert!(msg.contains("Missing 'source' field"));
1224 }
1225 _ => panic!("Expected Validation error"),
1226 }
1227 }
1228
1229 #[test]
1230 fn test_validate_x_aperture_secret_missing_name() {
1231 let validator = SpecValidator::new();
1232 let mut spec = create_test_spec();
1233 let mut components = Components::default();
1234
1235 let mut extensions = serde_json::Map::new();
1237 extensions.insert(
1238 crate::constants::EXT_APERTURE_SECRET.to_string(),
1239 serde_json::json!({
1240 "source": "env"
1241 }),
1242 );
1243
1244 components.security_schemes.insert(
1245 "bearerAuth".to_string(),
1246 ReferenceOr::Item(SecurityScheme::HTTP {
1247 scheme: constants::AUTH_SCHEME_BEARER.to_string(),
1248 bearer_format: None,
1249 description: None,
1250 extensions: extensions.into_iter().collect(),
1251 }),
1252 );
1253 spec.components = Some(components);
1254
1255 let result = validator.validate_with_mode(&spec, true).into_result();
1256 assert!(result.is_err());
1257 match result.unwrap_err() {
1258 Error::Internal {
1259 kind: ErrorKind::Validation,
1260 message: msg,
1261 ..
1262 } => {
1263 assert!(msg.contains("Missing 'name' field"));
1264 }
1265 _ => panic!("Expected Validation error"),
1266 }
1267 }
1268
1269 #[test]
1270 fn test_validate_x_aperture_secret_invalid_env_name() {
1271 let validator = SpecValidator::new();
1272 let mut spec = create_test_spec();
1273 let mut components = Components::default();
1274
1275 let mut extensions = serde_json::Map::new();
1277 extensions.insert(
1278 crate::constants::EXT_APERTURE_SECRET.to_string(),
1279 serde_json::json!({
1280 "source": "env",
1281 "name": "123_INVALID" }),
1283 );
1284
1285 components.security_schemes.insert(
1286 "bearerAuth".to_string(),
1287 ReferenceOr::Item(SecurityScheme::HTTP {
1288 scheme: constants::AUTH_SCHEME_BEARER.to_string(),
1289 bearer_format: None,
1290 description: None,
1291 extensions: extensions.into_iter().collect(),
1292 }),
1293 );
1294 spec.components = Some(components);
1295
1296 let result = validator.validate_with_mode(&spec, true).into_result();
1297 assert!(result.is_err());
1298 match result.unwrap_err() {
1299 Error::Internal {
1300 kind: ErrorKind::Validation,
1301 message: msg,
1302 ..
1303 } => {
1304 assert!(msg.contains("Invalid environment variable name"));
1305 }
1306 _ => panic!("Expected Validation error"),
1307 }
1308 }
1309
1310 #[test]
1311 fn test_validate_x_aperture_secret_unsupported_source() {
1312 let validator = SpecValidator::new();
1313 let mut spec = create_test_spec();
1314 let mut components = Components::default();
1315
1316 let mut extensions = serde_json::Map::new();
1318 extensions.insert(
1319 crate::constants::EXT_APERTURE_SECRET.to_string(),
1320 serde_json::json!({
1321 "source": "file", "name": "API_TOKEN"
1323 }),
1324 );
1325
1326 components.security_schemes.insert(
1327 "bearerAuth".to_string(),
1328 ReferenceOr::Item(SecurityScheme::HTTP {
1329 scheme: constants::AUTH_SCHEME_BEARER.to_string(),
1330 bearer_format: None,
1331 description: None,
1332 extensions: extensions.into_iter().collect(),
1333 }),
1334 );
1335 spec.components = Some(components);
1336
1337 let result = validator.validate_with_mode(&spec, true).into_result();
1338 assert!(result.is_err());
1339 match result.unwrap_err() {
1340 Error::Internal {
1341 kind: ErrorKind::Validation,
1342 message: msg,
1343 ..
1344 } => {
1345 assert!(msg.contains("Unsupported source 'file'"));
1346 }
1347 _ => panic!("Expected Validation error"),
1348 }
1349 }
1350}