aperture_cli/spec/
validator.rs

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