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