Skip to main content

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