aperture_cli/spec/
validator.rs

1use crate::error::Error;
2use openapiv3::{OpenAPI, Operation, Parameter, ReferenceOr, RequestBody, SecurityScheme};
3
4/// Result of validating an `OpenAPI` specification
5#[derive(Debug, Default)]
6pub struct ValidationResult {
7    /// Validation warnings for skipped endpoints
8    pub warnings: Vec<ValidationWarning>,
9    /// Validation errors that prevent spec usage
10    pub errors: Vec<Error>,
11}
12
13impl ValidationResult {
14    /// Creates a new empty validation result
15    #[must_use]
16    pub const fn new() -> Self {
17        Self {
18            warnings: Vec::new(),
19            errors: Vec::new(),
20        }
21    }
22
23    /// Converts to a Result for backward compatibility
24    ///
25    /// # Errors
26    ///
27    /// Returns the first validation error if any exist
28    pub fn into_result(self) -> Result<(), Error> {
29        self.errors.into_iter().next().map_or_else(|| Ok(()), Err)
30    }
31
32    /// Checks if validation passed (may have warnings but no errors)
33    #[must_use]
34    pub const fn is_valid(&self) -> bool {
35        self.errors.is_empty()
36    }
37
38    /// Adds a validation error
39    pub fn add_error(&mut self, error: Error) {
40        self.errors.push(error);
41    }
42
43    /// Adds a validation warning
44    pub fn add_warning(&mut self, warning: ValidationWarning) {
45        self.warnings.push(warning);
46    }
47}
48
49/// Warning about skipped functionality
50#[derive(Debug, Clone)]
51pub struct ValidationWarning {
52    /// The endpoint that was skipped
53    pub endpoint: UnsupportedEndpoint,
54    /// Human-readable reason for skipping
55    pub reason: String,
56}
57
58/// Details about an unsupported endpoint
59#[derive(Debug, Clone)]
60pub struct UnsupportedEndpoint {
61    /// HTTP path (e.g., "/api/upload")
62    pub path: String,
63    /// HTTP method (e.g., "POST")
64    pub method: String,
65    /// Content type that caused the skip (e.g., "multipart/form-data")
66    pub content_type: String,
67}
68
69/// Validates `OpenAPI` specifications for compatibility with Aperture
70pub struct SpecValidator;
71
72impl SpecValidator {
73    /// Creates a new `SpecValidator` instance
74    #[must_use]
75    pub const fn new() -> Self {
76        Self
77    }
78
79    /// Returns a human-readable reason for why a content type is not supported
80    fn get_unsupported_content_type_reason(content_type: &str) -> &'static str {
81        match content_type {
82            // Binary file types
83            "multipart/form-data" => "file uploads are not supported",
84            "application/octet-stream" => "binary data uploads are not supported",
85            ct if ct.starts_with("image/") => "image uploads are not supported",
86            "application/pdf" => "PDF uploads are not supported",
87
88            // Alternative text formats
89            "application/xml" | "text/xml" => "XML content is not supported",
90            "application/x-www-form-urlencoded" => "form-encoded data is not supported",
91            "text/plain" => "plain text content is not supported",
92            "text/csv" => "CSV content is not supported",
93
94            // JSON-compatible formats
95            "application/x-ndjson" => "newline-delimited JSON is not supported",
96            "application/graphql" => "GraphQL content is not supported",
97
98            // Generic fallback
99            _ => "is not supported",
100        }
101    }
102
103    /// Validates an `OpenAPI` specification for Aperture compatibility
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if:
108    /// - The spec contains unsupported security schemes (`OAuth2`, `OpenID` Connect)
109    /// - The spec uses $ref references in security schemes, parameters, or request bodies
110    /// - Required x-aperture-secret extensions are missing
111    /// - Parameters use content-based serialization
112    /// - Request bodies use non-JSON content types
113    #[deprecated(
114        since = "0.1.2",
115        note = "Use `validate_with_mode()` instead. This method defaults to strict mode which may not be desired."
116    )]
117    pub fn validate(&self, spec: &OpenAPI) -> Result<(), Error> {
118        self.validate_with_mode(spec, true).into_result()
119    }
120
121    /// Validates an `OpenAPI` specification with configurable strictness
122    ///
123    /// # Arguments
124    ///
125    /// * `spec` - The `OpenAPI` specification to validate
126    /// * `strict` - If true, returns errors for unsupported features. If false, collects warnings.
127    ///
128    /// # Returns
129    ///
130    /// Returns a `ValidationResult` containing any errors and/or warnings found
131    #[must_use]
132    pub fn validate_with_mode(&self, spec: &OpenAPI, strict: bool) -> ValidationResult {
133        let mut result = ValidationResult::new();
134
135        // Validate security schemes
136        if let Some(components) = &spec.components {
137            for (name, scheme_ref) in &components.security_schemes {
138                match scheme_ref {
139                    ReferenceOr::Item(scheme) => {
140                        if let Err(e) = Self::validate_security_scheme(name, scheme) {
141                            result.add_error(e);
142                        }
143                    }
144                    ReferenceOr::Reference { .. } => {
145                        result.add_error(Error::Validation(format!(
146                            "Security scheme references are not supported: '{name}'"
147                        )));
148                    }
149                }
150            }
151        }
152
153        // Validate operations
154        for (path, path_item_ref) in spec.paths.iter() {
155            if let ReferenceOr::Item(path_item) = path_item_ref {
156                for (method, operation_opt) in crate::spec::http_methods_iter(path_item) {
157                    if let Some(operation) = operation_opt {
158                        Self::validate_operation(
159                            path,
160                            &method.to_lowercase(),
161                            operation,
162                            &mut result,
163                            strict,
164                        );
165                    }
166                }
167            }
168        }
169
170        result
171    }
172
173    /// Validates a single security scheme
174    fn validate_security_scheme(name: &str, scheme: &SecurityScheme) -> Result<(), Error> {
175        // First validate the scheme type
176        match scheme {
177            SecurityScheme::APIKey { .. } => {
178                // API Key schemes are supported
179            }
180            SecurityScheme::HTTP {
181                scheme: http_scheme,
182                ..
183            } => {
184                if http_scheme != "bearer" && http_scheme != "basic" {
185                    return Err(Error::Validation(format!(
186                        "Unsupported HTTP scheme '{http_scheme}' in security scheme '{name}'. Only 'bearer' and 'basic' are supported."
187                    )));
188                }
189            }
190            SecurityScheme::OAuth2 { .. } => {
191                return Err(Error::Validation(format!(
192                    "OAuth2 security scheme '{name}' is not supported in v1.0."
193                )));
194            }
195            SecurityScheme::OpenIDConnect { .. } => {
196                return Err(Error::Validation(format!(
197                    "OpenID Connect security scheme '{name}' is not supported in v1.0."
198                )));
199            }
200        }
201
202        // Now validate x-aperture-secret extension if present
203        let (SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. }) =
204            scheme
205        else {
206            return Ok(());
207        };
208
209        if let Some(aperture_secret) = extensions.get("x-aperture-secret") {
210            // Validate that it's an object
211            let secret_obj = aperture_secret.as_object().ok_or_else(|| {
212                Error::Validation(format!(
213                    "Invalid x-aperture-secret in security scheme '{name}': must be an object"
214                ))
215            })?;
216
217            // Validate required 'source' field
218            let source = secret_obj
219                .get("source")
220                .ok_or_else(|| {
221                    Error::Validation(format!(
222                        "Missing 'source' field in x-aperture-secret for security scheme '{name}'"
223                    ))
224                })?
225                .as_str()
226                .ok_or_else(|| {
227                    Error::Validation(format!(
228                        "Invalid 'source' field in x-aperture-secret for security scheme '{name}': must be a string"
229                    ))
230                })?;
231
232            // Currently only 'env' source is supported
233            if source != "env" {
234                return Err(Error::Validation(format!(
235                    "Unsupported source '{source}' in x-aperture-secret for security scheme '{name}'. Only 'env' is supported."
236                )));
237            }
238
239            // Validate required 'name' field
240            let env_name = secret_obj
241                .get("name")
242                .ok_or_else(|| {
243                    Error::Validation(format!(
244                        "Missing 'name' field in x-aperture-secret for security scheme '{name}'"
245                    ))
246                })?
247                .as_str()
248                .ok_or_else(|| {
249                    Error::Validation(format!(
250                        "Invalid 'name' field in x-aperture-secret for security scheme '{name}': must be a string"
251                    ))
252                })?;
253
254            // Validate environment variable name format
255            if env_name.is_empty() {
256                return Err(Error::Validation(format!(
257                    "Empty 'name' field in x-aperture-secret for security scheme '{name}'"
258                )));
259            }
260
261            // Check for valid environment variable name (alphanumeric and underscore, not starting with digit)
262            if !env_name.chars().all(|c| c.is_alphanumeric() || c == '_')
263                || env_name.chars().next().is_some_and(char::is_numeric)
264            {
265                return Err(Error::Validation(format!(
266                    "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."
267                )));
268            }
269        }
270
271        Ok(())
272    }
273
274    /// Validates an operation against Aperture's supported features
275    fn validate_operation(
276        path: &str,
277        method: &str,
278        operation: &Operation,
279        result: &mut ValidationResult,
280        strict: bool,
281    ) {
282        // Validate parameters
283        for param_ref in &operation.parameters {
284            match param_ref {
285                ReferenceOr::Item(param) => {
286                    if let Err(e) = Self::validate_parameter(path, method, param) {
287                        result.add_error(e);
288                    }
289                }
290                ReferenceOr::Reference { .. } => {
291                    // Parameter references are now allowed and will be resolved during transformation
292                }
293            }
294        }
295
296        // Validate request body
297        if let Some(request_body_ref) = &operation.request_body {
298            match request_body_ref {
299                ReferenceOr::Item(request_body) => {
300                    Self::validate_request_body(path, method, request_body, result, strict);
301                }
302                ReferenceOr::Reference { .. } => {
303                    result.add_error(Error::Validation(format!(
304                        "Request body references are not supported in {method} {path}."
305                    )));
306                }
307            }
308        }
309    }
310
311    /// Validates a parameter against Aperture's supported features
312    fn validate_parameter(path: &str, method: &str, param: &Parameter) -> Result<(), Error> {
313        let param_data = match param {
314            Parameter::Query { parameter_data, .. }
315            | Parameter::Header { parameter_data, .. }
316            | Parameter::Path { parameter_data, .. }
317            | Parameter::Cookie { parameter_data, .. } => parameter_data,
318        };
319
320        match &param_data.format {
321            openapiv3::ParameterSchemaOrContent::Schema(_) => Ok(()),
322            openapiv3::ParameterSchemaOrContent::Content(_) => {
323                Err(Error::Validation(format!(
324                    "Parameter '{}' in {method} {path} uses unsupported content-based serialization. Only schema-based parameters are supported.",
325                    param_data.name
326                )))
327            }
328        }
329    }
330
331    /// Helper function to check if a content type is JSON
332    fn is_json_content_type(content_type: &str) -> bool {
333        // Extract base type before any parameters (e.g., "application/json; charset=utf-8" -> "application/json")
334        let base_type = content_type
335            .split(';')
336            .next()
337            .unwrap_or(content_type)
338            .trim();
339
340        // Support standard JSON and all JSON variants (e.g., application/vnd.api+json, application/ld+json)
341        base_type.eq_ignore_ascii_case("application/json")
342            || base_type.to_lowercase().ends_with("+json")
343    }
344
345    /// Validates a request body against Aperture's supported features
346    fn validate_request_body(
347        path: &str,
348        method: &str,
349        request_body: &RequestBody,
350        result: &mut ValidationResult,
351        strict: bool,
352    ) {
353        let (has_json, unsupported_types) = Self::categorize_content_types(request_body);
354
355        if unsupported_types.is_empty() {
356            return;
357        }
358
359        if strict {
360            Self::add_strict_mode_errors(path, method, &unsupported_types, result);
361        } else {
362            Self::add_non_strict_warning(path, method, has_json, &unsupported_types, result);
363        }
364    }
365
366    /// Categorize content types into JSON and unsupported
367    fn categorize_content_types(request_body: &RequestBody) -> (bool, Vec<&String>) {
368        let mut has_json = false;
369        let mut unsupported_types = Vec::new();
370
371        for content_type in request_body.content.keys() {
372            if Self::is_json_content_type(content_type) {
373                has_json = true;
374            } else {
375                unsupported_types.push(content_type);
376            }
377        }
378
379        (has_json, unsupported_types)
380    }
381
382    /// Add errors for unsupported content types in strict mode
383    fn add_strict_mode_errors(
384        path: &str,
385        method: &str,
386        unsupported_types: &[&String],
387        result: &mut ValidationResult,
388    ) {
389        for content_type in unsupported_types {
390            let error = Error::Validation(format!(
391                "Unsupported request body content type '{content_type}' in {method} {path}. Only 'application/json' is supported in v1.0."
392            ));
393            result.add_error(error);
394        }
395    }
396
397    /// Add warning for unsupported content types in non-strict mode
398    fn add_non_strict_warning(
399        path: &str,
400        method: &str,
401        has_json: bool,
402        unsupported_types: &[&String],
403        result: &mut ValidationResult,
404    ) {
405        let content_types: Vec<String> = unsupported_types
406            .iter()
407            .map(|ct| {
408                let reason = Self::get_unsupported_content_type_reason(ct);
409                format!("{ct} ({reason})")
410            })
411            .collect();
412
413        let reason = if has_json {
414            "endpoint has unsupported content types alongside JSON"
415        } else {
416            "endpoint has no supported content types"
417        };
418
419        let warning = ValidationWarning {
420            endpoint: UnsupportedEndpoint {
421                path: path.to_string(),
422                method: method.to_uppercase(),
423                content_type: content_types.join(", "),
424            },
425            reason: reason.to_string(),
426        };
427
428        result.add_warning(warning);
429    }
430}
431
432impl Default for SpecValidator {
433    fn default() -> Self {
434        Self::new()
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use openapiv3::{Components, Info, OpenAPI};
442
443    fn create_test_spec() -> OpenAPI {
444        OpenAPI {
445            openapi: "3.0.0".to_string(),
446            info: Info {
447                title: "Test API".to_string(),
448                version: "1.0.0".to_string(),
449                ..Default::default()
450            },
451            ..Default::default()
452        }
453    }
454
455    #[test]
456    fn test_validate_empty_spec() {
457        let validator = SpecValidator::new();
458        let spec = create_test_spec();
459        assert!(validator
460            .validate_with_mode(&spec, true)
461            .into_result()
462            .is_ok());
463    }
464
465    #[test]
466    fn test_validate_oauth2_scheme_rejected() {
467        let validator = SpecValidator::new();
468        let mut spec = create_test_spec();
469        let mut components = Components::default();
470        components.security_schemes.insert(
471            "oauth".to_string(),
472            ReferenceOr::Item(SecurityScheme::OAuth2 {
473                flows: Default::default(),
474                description: None,
475                extensions: Default::default(),
476            }),
477        );
478        spec.components = Some(components);
479
480        let result = validator.validate_with_mode(&spec, true).into_result();
481        assert!(result.is_err());
482        match result.unwrap_err() {
483            Error::Validation(msg) => {
484                assert!(msg.contains("OAuth2"));
485                assert!(msg.contains("not supported"));
486            }
487            _ => panic!("Expected Validation error"),
488        }
489    }
490
491    #[test]
492    fn test_validate_reference_rejected() {
493        let validator = SpecValidator::new();
494        let mut spec = create_test_spec();
495        let mut components = Components::default();
496        components.security_schemes.insert(
497            "auth".to_string(),
498            ReferenceOr::Reference {
499                reference: "#/components/securitySchemes/BasicAuth".to_string(),
500            },
501        );
502        spec.components = Some(components);
503
504        let result = validator.validate_with_mode(&spec, true).into_result();
505        assert!(result.is_err());
506        match result.unwrap_err() {
507            Error::Validation(msg) => {
508                assert!(msg.contains("references are not supported"));
509            }
510            _ => panic!("Expected Validation error"),
511        }
512    }
513
514    #[test]
515    fn test_validate_supported_schemes() {
516        let validator = SpecValidator::new();
517        let mut spec = create_test_spec();
518        let mut components = Components::default();
519
520        // Add API key scheme
521        components.security_schemes.insert(
522            "apiKey".to_string(),
523            ReferenceOr::Item(SecurityScheme::APIKey {
524                location: openapiv3::APIKeyLocation::Header,
525                name: "X-API-Key".to_string(),
526                description: None,
527                extensions: Default::default(),
528            }),
529        );
530
531        // Add HTTP bearer scheme
532        components.security_schemes.insert(
533            "bearer".to_string(),
534            ReferenceOr::Item(SecurityScheme::HTTP {
535                scheme: "bearer".to_string(),
536                bearer_format: Some("JWT".to_string()),
537                description: None,
538                extensions: Default::default(),
539            }),
540        );
541
542        // Add HTTP basic scheme (now supported)
543        components.security_schemes.insert(
544            "basic".to_string(),
545            ReferenceOr::Item(SecurityScheme::HTTP {
546                scheme: "basic".to_string(),
547                bearer_format: None,
548                description: None,
549                extensions: Default::default(),
550            }),
551        );
552
553        spec.components = Some(components);
554
555        assert!(validator
556            .validate_with_mode(&spec, true)
557            .into_result()
558            .is_ok());
559    }
560
561    #[test]
562    fn test_validate_with_mode_non_strict_mixed_content() {
563        use openapiv3::{
564            MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
565        };
566
567        let validator = SpecValidator::new();
568        let mut spec = create_test_spec();
569
570        // Endpoint with both JSON and multipart - should be accepted without warnings
571        let mut request_body = RequestBody::default();
572        request_body
573            .content
574            .insert("multipart/form-data".to_string(), MediaType::default());
575        request_body
576            .content
577            .insert("application/json".to_string(), MediaType::default());
578        request_body.required = true;
579
580        let mut path_item = PathItem::default();
581        path_item.post = Some(Operation {
582            operation_id: Some("uploadFile".to_string()),
583            tags: vec!["files".to_string()],
584            request_body: Some(ReferenceOr::Item(request_body)),
585            responses: Responses::default(),
586            ..Default::default()
587        });
588
589        spec.paths
590            .paths
591            .insert("/upload".to_string(), PathRef::Item(path_item));
592
593        // Non-strict mode should produce warnings for unsupported types even when JSON is supported
594        let result = validator.validate_with_mode(&spec, false);
595        assert!(result.is_valid(), "Non-strict mode should be valid");
596        assert_eq!(
597            result.warnings.len(),
598            1,
599            "Should have one warning for mixed content types"
600        );
601        assert_eq!(result.errors.len(), 0, "Should have no errors");
602
603        // Check the warning details
604        let warning = &result.warnings[0];
605        assert_eq!(warning.endpoint.path, "/upload");
606        assert_eq!(warning.endpoint.method, "POST");
607        assert!(warning
608            .endpoint
609            .content_type
610            .contains("multipart/form-data"));
611        assert!(warning
612            .reason
613            .contains("unsupported content types alongside JSON"));
614    }
615
616    #[test]
617    fn test_validate_with_mode_non_strict_only_unsupported() {
618        use openapiv3::{
619            MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
620        };
621
622        let validator = SpecValidator::new();
623        let mut spec = create_test_spec();
624
625        // Endpoint with only unsupported content type - should produce warning
626        let mut request_body = RequestBody::default();
627        request_body
628            .content
629            .insert("multipart/form-data".to_string(), MediaType::default());
630        request_body.required = true;
631
632        let mut path_item = PathItem::default();
633        path_item.post = Some(Operation {
634            operation_id: Some("uploadFile".to_string()),
635            tags: vec!["files".to_string()],
636            request_body: Some(ReferenceOr::Item(request_body)),
637            responses: Responses::default(),
638            ..Default::default()
639        });
640
641        spec.paths
642            .paths
643            .insert("/upload".to_string(), PathRef::Item(path_item));
644
645        // Non-strict mode should produce warning for endpoint with no supported types
646        let result = validator.validate_with_mode(&spec, false);
647        assert!(result.is_valid(), "Non-strict mode should be valid");
648        assert_eq!(result.warnings.len(), 1, "Should have one warning");
649        assert_eq!(result.errors.len(), 0, "Should have no errors");
650
651        let warning = &result.warnings[0];
652        assert_eq!(warning.endpoint.path, "/upload");
653        assert_eq!(warning.endpoint.method, "POST");
654        assert!(warning
655            .endpoint
656            .content_type
657            .contains("multipart/form-data"));
658        assert!(warning.reason.contains("no supported content types"));
659    }
660
661    #[test]
662    fn test_validate_with_mode_strict() {
663        use openapiv3::{
664            MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
665        };
666
667        let validator = SpecValidator::new();
668        let mut spec = create_test_spec();
669
670        let mut request_body = RequestBody::default();
671        request_body
672            .content
673            .insert("multipart/form-data".to_string(), MediaType::default());
674        request_body.required = true;
675
676        let mut path_item = PathItem::default();
677        path_item.post = Some(Operation {
678            operation_id: Some("uploadFile".to_string()),
679            tags: vec!["files".to_string()],
680            request_body: Some(ReferenceOr::Item(request_body)),
681            responses: Responses::default(),
682            ..Default::default()
683        });
684
685        spec.paths
686            .paths
687            .insert("/upload".to_string(), PathRef::Item(path_item));
688
689        // Strict mode should produce errors
690        let result = validator.validate_with_mode(&spec, true);
691        assert!(!result.is_valid(), "Strict mode should be invalid");
692        assert_eq!(result.warnings.len(), 0, "Should have no warnings");
693        assert_eq!(result.errors.len(), 1, "Should have one error");
694
695        match &result.errors[0] {
696            Error::Validation(msg) => {
697                assert!(msg.contains("multipart/form-data"));
698                assert!(msg.contains("v1.0"));
699            }
700            _ => panic!("Expected Validation error"),
701        }
702    }
703
704    #[test]
705    fn test_validate_with_mode_multiple_content_types() {
706        use openapiv3::{
707            MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
708        };
709
710        let validator = SpecValidator::new();
711        let mut spec = create_test_spec();
712
713        // Add multiple endpoints with different content types
714        let mut path_item1 = PathItem::default();
715        let mut request_body1 = RequestBody::default();
716        request_body1
717            .content
718            .insert("application/xml".to_string(), MediaType::default());
719        path_item1.post = Some(Operation {
720            operation_id: Some("postXml".to_string()),
721            tags: vec!["data".to_string()],
722            request_body: Some(ReferenceOr::Item(request_body1)),
723            responses: Responses::default(),
724            ..Default::default()
725        });
726        spec.paths
727            .paths
728            .insert("/xml".to_string(), PathRef::Item(path_item1));
729
730        let mut path_item2 = PathItem::default();
731        let mut request_body2 = RequestBody::default();
732        request_body2
733            .content
734            .insert("text/plain".to_string(), MediaType::default());
735        path_item2.put = Some(Operation {
736            operation_id: Some("putText".to_string()),
737            tags: vec!["data".to_string()],
738            request_body: Some(ReferenceOr::Item(request_body2)),
739            responses: Responses::default(),
740            ..Default::default()
741        });
742        spec.paths
743            .paths
744            .insert("/text".to_string(), PathRef::Item(path_item2));
745
746        // Non-strict mode should have warnings for both
747        let result = validator.validate_with_mode(&spec, false);
748        assert!(result.is_valid());
749        assert_eq!(result.warnings.len(), 2);
750
751        let warning_paths: Vec<&str> = result
752            .warnings
753            .iter()
754            .map(|w| w.endpoint.path.as_str())
755            .collect();
756        assert!(warning_paths.contains(&"/xml"));
757        assert!(warning_paths.contains(&"/text"));
758    }
759
760    #[test]
761    fn test_validate_with_mode_multiple_unsupported_types_single_endpoint() {
762        use openapiv3::{
763            MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
764        };
765
766        let validator = SpecValidator::new();
767        let mut spec = create_test_spec();
768
769        // Endpoint with multiple unsupported content types - should produce single warning
770        let mut request_body = RequestBody::default();
771        request_body
772            .content
773            .insert("multipart/form-data".to_string(), MediaType::default());
774        request_body
775            .content
776            .insert("application/xml".to_string(), MediaType::default());
777        request_body
778            .content
779            .insert("text/plain".to_string(), MediaType::default());
780        request_body.required = true;
781
782        let mut path_item = PathItem::default();
783        path_item.post = Some(Operation {
784            operation_id: Some("uploadData".to_string()),
785            tags: vec!["data".to_string()],
786            request_body: Some(ReferenceOr::Item(request_body)),
787            responses: Responses::default(),
788            ..Default::default()
789        });
790
791        spec.paths
792            .paths
793            .insert("/data".to_string(), PathRef::Item(path_item));
794
795        // Non-strict mode should produce single warning listing all unsupported types
796        let result = validator.validate_with_mode(&spec, false);
797        assert!(result.is_valid(), "Non-strict mode should be valid");
798        assert_eq!(result.warnings.len(), 1, "Should have exactly one warning");
799        assert_eq!(result.errors.len(), 0, "Should have no errors");
800
801        let warning = &result.warnings[0];
802        assert_eq!(warning.endpoint.path, "/data");
803        assert_eq!(warning.endpoint.method, "POST");
804        // Check that all content types are mentioned
805        assert!(warning
806            .endpoint
807            .content_type
808            .contains("multipart/form-data"));
809        assert!(warning.endpoint.content_type.contains("application/xml"));
810        assert!(warning.endpoint.content_type.contains("text/plain"));
811        assert!(warning.reason.contains("no supported content types"));
812    }
813
814    #[test]
815    fn test_validate_unsupported_http_scheme() {
816        let validator = SpecValidator::new();
817        let mut spec = create_test_spec();
818        let mut components = Components::default();
819
820        components.security_schemes.insert(
821            "digest".to_string(),
822            ReferenceOr::Item(SecurityScheme::HTTP {
823                scheme: "digest".to_string(),
824                bearer_format: None,
825                description: None,
826                extensions: Default::default(),
827            }),
828        );
829
830        spec.components = Some(components);
831
832        let result = validator.validate_with_mode(&spec, true).into_result();
833        assert!(result.is_err());
834        match result.unwrap_err() {
835            Error::Validation(msg) => {
836                assert!(msg.contains("Unsupported HTTP scheme 'digest'"));
837            }
838            _ => panic!("Expected Validation error"),
839        }
840    }
841
842    #[test]
843    fn test_validate_parameter_reference_allowed() {
844        use openapiv3::{Operation, PathItem, ReferenceOr as PathRef, Responses};
845
846        let validator = SpecValidator::new();
847        let mut spec = create_test_spec();
848
849        let mut path_item = PathItem::default();
850        path_item.get = Some(Operation {
851            parameters: vec![ReferenceOr::Reference {
852                reference: "#/components/parameters/UserId".to_string(),
853            }],
854            responses: Responses::default(),
855            ..Default::default()
856        });
857
858        spec.paths
859            .paths
860            .insert("/users/{id}".to_string(), PathRef::Item(path_item));
861
862        // Parameter references should now be allowed
863        let result = validator.validate_with_mode(&spec, true).into_result();
864        assert!(result.is_ok());
865    }
866
867    #[test]
868    fn test_validate_request_body_non_json_rejected() {
869        use openapiv3::{
870            MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
871        };
872
873        let validator = SpecValidator::new();
874        let mut spec = create_test_spec();
875
876        let mut request_body = RequestBody::default();
877        request_body
878            .content
879            .insert("application/xml".to_string(), MediaType::default());
880        request_body.required = true;
881
882        let mut path_item = PathItem::default();
883        path_item.post = Some(Operation {
884            request_body: Some(ReferenceOr::Item(request_body)),
885            responses: Responses::default(),
886            ..Default::default()
887        });
888
889        spec.paths
890            .paths
891            .insert("/users".to_string(), PathRef::Item(path_item));
892
893        let result = validator.validate_with_mode(&spec, true).into_result();
894        assert!(result.is_err());
895        match result.unwrap_err() {
896            Error::Validation(msg) => {
897                assert!(msg.contains("Unsupported request body content type 'application/xml'"));
898            }
899            _ => panic!("Expected Validation error"),
900        }
901    }
902
903    #[test]
904    fn test_validate_x_aperture_secret_valid() {
905        let validator = SpecValidator::new();
906        let mut spec = create_test_spec();
907        let mut components = Components::default();
908
909        // Create a bearer auth scheme with valid x-aperture-secret
910        let mut extensions = serde_json::Map::new();
911        extensions.insert(
912            "x-aperture-secret".to_string(),
913            serde_json::json!({
914                "source": "env",
915                "name": "API_TOKEN"
916            }),
917        );
918
919        components.security_schemes.insert(
920            "bearerAuth".to_string(),
921            ReferenceOr::Item(SecurityScheme::HTTP {
922                scheme: "bearer".to_string(),
923                bearer_format: None,
924                description: None,
925                extensions: extensions.into_iter().collect(),
926            }),
927        );
928        spec.components = Some(components);
929
930        assert!(validator
931            .validate_with_mode(&spec, true)
932            .into_result()
933            .is_ok());
934    }
935
936    #[test]
937    fn test_validate_x_aperture_secret_missing_source() {
938        let validator = SpecValidator::new();
939        let mut spec = create_test_spec();
940        let mut components = Components::default();
941
942        // Create a bearer auth scheme with invalid x-aperture-secret (missing source)
943        let mut extensions = serde_json::Map::new();
944        extensions.insert(
945            "x-aperture-secret".to_string(),
946            serde_json::json!({
947                "name": "API_TOKEN"
948            }),
949        );
950
951        components.security_schemes.insert(
952            "bearerAuth".to_string(),
953            ReferenceOr::Item(SecurityScheme::HTTP {
954                scheme: "bearer".to_string(),
955                bearer_format: None,
956                description: None,
957                extensions: extensions.into_iter().collect(),
958            }),
959        );
960        spec.components = Some(components);
961
962        let result = validator.validate_with_mode(&spec, true).into_result();
963        assert!(result.is_err());
964        match result.unwrap_err() {
965            Error::Validation(msg) => {
966                assert!(msg.contains("Missing 'source' field"));
967            }
968            _ => panic!("Expected Validation error"),
969        }
970    }
971
972    #[test]
973    fn test_validate_x_aperture_secret_missing_name() {
974        let validator = SpecValidator::new();
975        let mut spec = create_test_spec();
976        let mut components = Components::default();
977
978        // Create a bearer auth scheme with invalid x-aperture-secret (missing name)
979        let mut extensions = serde_json::Map::new();
980        extensions.insert(
981            "x-aperture-secret".to_string(),
982            serde_json::json!({
983                "source": "env"
984            }),
985        );
986
987        components.security_schemes.insert(
988            "bearerAuth".to_string(),
989            ReferenceOr::Item(SecurityScheme::HTTP {
990                scheme: "bearer".to_string(),
991                bearer_format: None,
992                description: None,
993                extensions: extensions.into_iter().collect(),
994            }),
995        );
996        spec.components = Some(components);
997
998        let result = validator.validate_with_mode(&spec, true).into_result();
999        assert!(result.is_err());
1000        match result.unwrap_err() {
1001            Error::Validation(msg) => {
1002                assert!(msg.contains("Missing 'name' field"));
1003            }
1004            _ => panic!("Expected Validation error"),
1005        }
1006    }
1007
1008    #[test]
1009    fn test_validate_x_aperture_secret_invalid_env_name() {
1010        let validator = SpecValidator::new();
1011        let mut spec = create_test_spec();
1012        let mut components = Components::default();
1013
1014        // Create a bearer auth scheme with invalid environment variable name
1015        let mut extensions = serde_json::Map::new();
1016        extensions.insert(
1017            "x-aperture-secret".to_string(),
1018            serde_json::json!({
1019                "source": "env",
1020                "name": "123_INVALID"  // Starts with digit
1021            }),
1022        );
1023
1024        components.security_schemes.insert(
1025            "bearerAuth".to_string(),
1026            ReferenceOr::Item(SecurityScheme::HTTP {
1027                scheme: "bearer".to_string(),
1028                bearer_format: None,
1029                description: None,
1030                extensions: extensions.into_iter().collect(),
1031            }),
1032        );
1033        spec.components = Some(components);
1034
1035        let result = validator.validate_with_mode(&spec, true).into_result();
1036        assert!(result.is_err());
1037        match result.unwrap_err() {
1038            Error::Validation(msg) => {
1039                assert!(msg.contains("Invalid environment variable name"));
1040            }
1041            _ => panic!("Expected Validation error"),
1042        }
1043    }
1044
1045    #[test]
1046    fn test_validate_x_aperture_secret_unsupported_source() {
1047        let validator = SpecValidator::new();
1048        let mut spec = create_test_spec();
1049        let mut components = Components::default();
1050
1051        // Create a bearer auth scheme with unsupported source
1052        let mut extensions = serde_json::Map::new();
1053        extensions.insert(
1054            "x-aperture-secret".to_string(),
1055            serde_json::json!({
1056                "source": "file",  // Not supported
1057                "name": "API_TOKEN"
1058            }),
1059        );
1060
1061        components.security_schemes.insert(
1062            "bearerAuth".to_string(),
1063            ReferenceOr::Item(SecurityScheme::HTTP {
1064                scheme: "bearer".to_string(),
1065                bearer_format: None,
1066                description: None,
1067                extensions: extensions.into_iter().collect(),
1068            }),
1069        );
1070        spec.components = Some(components);
1071
1072        let result = validator.validate_with_mode(&spec, true).into_result();
1073        assert!(result.is_err());
1074        match result.unwrap_err() {
1075            Error::Validation(msg) => {
1076                assert!(msg.contains("Unsupported source 'file'"));
1077            }
1078            _ => panic!("Expected Validation error"),
1079        }
1080    }
1081}