aperture_cli/spec/
validator.rs

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/// Result of validating an `OpenAPI` specification
8#[derive(Debug, Default)]
9pub struct ValidationResult {
10    /// Validation warnings for skipped endpoints
11    pub warnings: Vec<ValidationWarning>,
12    /// Validation errors that prevent spec usage
13    pub errors: Vec<Error>,
14}
15
16impl ValidationResult {
17    /// Creates a new empty validation result
18    #[must_use]
19    pub const fn new() -> Self {
20        Self {
21            warnings: Vec::new(),
22            errors: Vec::new(),
23        }
24    }
25
26    /// Converts to a Result for backward compatibility
27    ///
28    /// # Errors
29    ///
30    /// Returns the first validation error if any exist
31    pub fn into_result(self) -> Result<(), Error> {
32        self.errors.into_iter().next().map_or_else(|| Ok(()), Err)
33    }
34
35    /// Checks if validation passed (may have warnings but no errors)
36    #[must_use]
37    pub const fn is_valid(&self) -> bool {
38        self.errors.is_empty()
39    }
40
41    /// Adds a validation error
42    pub fn add_error(&mut self, error: Error) {
43        self.errors.push(error);
44    }
45
46    /// Adds a validation warning
47    pub fn add_warning(&mut self, warning: ValidationWarning) {
48        self.warnings.push(warning);
49    }
50}
51
52/// Warning about skipped functionality
53#[derive(Debug, Clone)]
54pub struct ValidationWarning {
55    /// The endpoint that was skipped
56    pub endpoint: UnsupportedEndpoint,
57    /// Human-readable reason for skipping
58    pub reason: String,
59}
60
61impl ValidationWarning {
62    /// Determines if this warning should result in a skipped endpoint
63    #[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    /// Converts to a skipped endpoint tuple if applicable
70    #[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/// Details about an unsupported endpoint
81#[derive(Debug, Clone)]
82pub struct UnsupportedEndpoint {
83    /// HTTP path (e.g., "/api/upload")
84    pub path: String,
85    /// HTTP method (e.g., "POST")
86    pub method: String,
87    /// Content type that caused the skip (e.g., "multipart/form-data")
88    pub content_type: String,
89}
90
91/// Validates `OpenAPI` specifications for compatibility with Aperture
92pub struct SpecValidator;
93
94impl SpecValidator {
95    /// Creates a new `SpecValidator` instance
96    #[must_use]
97    pub const fn new() -> Self {
98        Self
99    }
100
101    /// Returns a human-readable reason for why a content type is not supported
102    fn get_unsupported_content_type_reason(content_type: &str) -> &'static str {
103        match content_type {
104            // Binary file types
105            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            // Alternative text formats
113            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            // JSON-compatible formats
121            constants::CONTENT_TYPE_NDJSON => "newline-delimited JSON is not supported",
122            constants::CONTENT_TYPE_GRAPHQL => "GraphQL content is not supported",
123
124            // Generic fallback
125            _ => "is not supported",
126        }
127    }
128
129    /// Validates an `OpenAPI` specification for Aperture compatibility
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if:
134    /// - The spec contains unsupported security schemes (`OAuth2`, `OpenID` Connect)
135    /// - The spec uses $ref references in security schemes, parameters, or request bodies
136    /// - Required x-aperture-secret extensions are missing
137    /// - Parameters use content-based serialization
138    /// - Request bodies use non-JSON content types
139    #[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    /// Validates an `OpenAPI` specification with configurable strictness
148    ///
149    /// # Arguments
150    ///
151    /// * `spec` - The `OpenAPI` specification to validate
152    /// * `strict` - If true, returns errors for unsupported features. If false, collects warnings.
153    ///
154    /// # Returns
155    ///
156    /// Returns a `ValidationResult` containing any errors and/or warnings found
157    #[must_use]
158    pub fn validate_with_mode(&self, spec: &OpenAPI, strict: bool) -> ValidationResult {
159        let mut result = ValidationResult::new();
160
161        // Validate security schemes and track unsupported ones
162        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        // Validate operations
187        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    /// Validates a single security scheme and returns the scheme type for tracking
209    fn validate_security_scheme(
210        name: &str,
211        scheme: &SecurityScheme,
212    ) -> Result<Option<String>, Error> {
213        // First validate the scheme type and identify if it's unsupported
214        let unsupported_reason = match scheme {
215            SecurityScheme::APIKey { .. } => None, // API Key schemes are supported
216            SecurityScheme::HTTP {
217                scheme: http_scheme,
218                ..
219            } => {
220                // Only reject known complex schemes that we explicitly don't support
221                // All other schemes are treated as bearer-like tokens
222                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 // Any other HTTP scheme (bearer, basic, token, apikey, custom, etc.) is allowed
229                }
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 we found an unsupported scheme, return it as an error (to be converted to warning later)
240        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        // Now validate x-aperture-secret extension if present
247        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            // Validate that it's an object
255            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            // Validate required 'source' field
262            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            // Currently only 'env' source is supported
277            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            // Validate required 'name' field
284            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            // Validate environment variable name format
299            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            // Check for valid environment variable name (alphanumeric and underscore, not starting with digit)
306            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    /// Handles security scheme validation errors with proper error/warning categorization
319    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                // Track unsupported schemes for later operation validation
338                unsupported_schemes.insert(scheme_name.to_string(), message.to_string());
339            }
340            _ => {
341                // Other validation errors are still errors even in non-strict mode
342                result.add_error(error);
343            }
344        }
345    }
346
347    /// Determines if an operation should be skipped due to unsupported authentication
348    /// Returns true if the operation was skipped (warning added), false otherwise
349    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        // Skip auth validation in strict mode or when no schemes are unsupported
359        if strict || unsupported_schemes.is_empty() {
360            return false;
361        }
362
363        // Get security requirements (operation-level or global)
364        let Some(reqs) = operation.security.as_ref().or(spec.security.as_ref()) else {
365            return false;
366        };
367
368        // Skip if empty security requirements
369        if reqs.is_empty() {
370            return false;
371        }
372
373        // Check if all auth schemes are unsupported
374        if !Self::should_skip_due_to_auth(reqs, unsupported_schemes) {
375            return false;
376        }
377
378        // Generate warning for skipped operation
379        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    /// Formats unsupported scheme details for warning messages
395    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                    // Extract the specific reason from the validation message
404                    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    /// Formats the reason message for authentication-related skips
419    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    /// Check if operation should be skipped because all its auth schemes are unsupported
441    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    /// Validates an operation against Aperture's supported features
452    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        // Check if operation should be skipped due to unsupported authentication
462        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        // Validate parameters
475        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                    // Parameter references are now allowed and will be resolved during transformation
484                }
485            }
486        }
487
488        // Validate request body
489        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    /// Validates a parameter against Aperture's supported features
504    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 &param_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    /// Helper function to check if a content type is JSON
524    fn is_json_content_type(content_type: &str) -> bool {
525        // Extract base type before any parameters (e.g., "application/json; charset=utf-8" -> "application/json")
526        let base_type = content_type
527            .split(';')
528            .next()
529            .unwrap_or(content_type)
530            .trim();
531
532        // Support standard JSON and all JSON variants (e.g., application/vnd.api+json, application/ld+json)
533        base_type.eq_ignore_ascii_case(constants::CONTENT_TYPE_JSON)
534            || base_type.to_lowercase().ends_with("+json")
535    }
536
537    /// Validates a request body against Aperture's supported features
538    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    /// Categorize content types into JSON and unsupported
559    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    /// Add errors for unsupported content types in strict mode
575    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    /// Add warning for unsupported content types in non-strict mode
590    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        // Add API key scheme
721        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        // Add HTTP bearer scheme
732        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        // Add HTTP basic scheme (now supported)
743        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        // Endpoint with both JSON and multipart - should be accepted without warnings
771        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        // Non-strict mode should produce warnings for unsupported types even when JSON is supported
795        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        // Check the warning details
805        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        // Endpoint with only unsupported content type - should produce warning
827        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        // Non-strict mode should produce warning for endpoint with no supported types
847        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        // Strict mode should produce errors
891        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        // Add multiple endpoints with different content types
919        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        // Non-strict mode should have warnings for both
954        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        // Endpoint with multiple unsupported content types - should produce single warning
977        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        // Non-strict mode should produce single warning listing all unsupported types
1005        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        // Check that all content types are mentioned
1014        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        // Use 'negotiate' which is explicitly rejected
1036        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        // Test various custom schemes that should now be allowed
1069        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        // All custom schemes should be valid
1086        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        // Parameter references should now be allowed
1111        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        // Create a bearer auth scheme with valid x-aperture-secret
1163        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        // Create a bearer auth scheme with invalid x-aperture-secret (missing source)
1196        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        // Create a bearer auth scheme with invalid x-aperture-secret (missing name)
1236        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        // Create a bearer auth scheme with invalid environment variable name
1276        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"  // Starts with digit
1282            }),
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        // Create a bearer auth scheme with unsupported source
1317        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",  // Not supported
1322                "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}