aperture_cli/spec/
validator.rs

1use crate::error::Error;
2use openapiv3::{OpenAPI, Operation, Parameter, ReferenceOr, RequestBody, SecurityScheme};
3
4/// Validates `OpenAPI` specifications for compatibility with Aperture
5pub struct SpecValidator;
6
7impl SpecValidator {
8    /// Creates a new `SpecValidator` instance
9    #[must_use]
10    pub const fn new() -> Self {
11        Self
12    }
13
14    /// Validates an `OpenAPI` specification for Aperture compatibility
15    ///
16    /// # Errors
17    ///
18    /// Returns an error if:
19    /// - The spec contains unsupported security schemes (`OAuth2`, `OpenID` Connect)
20    /// - The spec uses $ref references in security schemes, parameters, or request bodies
21    /// - Required x-aperture-secret extensions are missing
22    /// - Parameters use content-based serialization
23    /// - Request bodies use non-JSON content types
24    pub fn validate(&self, spec: &OpenAPI) -> Result<(), Error> {
25        // Validate security schemes
26        if let Some(components) = &spec.components {
27            for (name, scheme_ref) in &components.security_schemes {
28                match scheme_ref {
29                    ReferenceOr::Item(scheme) => {
30                        Self::validate_security_scheme(name, scheme)?;
31                    }
32                    ReferenceOr::Reference { .. } => {
33                        return Err(Error::Validation(format!(
34                            "Security scheme references are not supported: '{name}'"
35                        )));
36                    }
37                }
38            }
39        }
40
41        // Validate operations
42        for (path, path_item_ref) in spec.paths.iter() {
43            if let ReferenceOr::Item(path_item) = path_item_ref {
44                for (method, operation_opt) in crate::spec::http_methods_iter(path_item) {
45                    if let Some(operation) = operation_opt {
46                        Self::validate_operation(path, &method.to_lowercase(), operation)?;
47                    }
48                }
49            }
50        }
51
52        Ok(())
53    }
54
55    /// Validates a single security scheme
56    fn validate_security_scheme(name: &str, scheme: &SecurityScheme) -> Result<(), Error> {
57        // First validate the scheme type
58        match scheme {
59            SecurityScheme::APIKey { .. } => {
60                // API Key schemes are supported
61            }
62            SecurityScheme::HTTP {
63                scheme: http_scheme,
64                ..
65            } => {
66                if http_scheme != "bearer" && http_scheme != "basic" {
67                    return Err(Error::Validation(format!(
68                        "Unsupported HTTP scheme '{http_scheme}' in security scheme '{name}'. Only 'bearer' and 'basic' are supported."
69                    )));
70                }
71            }
72            SecurityScheme::OAuth2 { .. } => {
73                return Err(Error::Validation(format!(
74                    "OAuth2 security scheme '{name}' is not supported in v1.0."
75                )));
76            }
77            SecurityScheme::OpenIDConnect { .. } => {
78                return Err(Error::Validation(format!(
79                    "OpenID Connect security scheme '{name}' is not supported in v1.0."
80                )));
81            }
82        }
83
84        // Now validate x-aperture-secret extension if present
85        let (SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. }) =
86            scheme
87        else {
88            return Ok(());
89        };
90
91        if let Some(aperture_secret) = extensions.get("x-aperture-secret") {
92            // Validate that it's an object
93            let secret_obj = aperture_secret.as_object().ok_or_else(|| {
94                Error::Validation(format!(
95                    "Invalid x-aperture-secret in security scheme '{name}': must be an object"
96                ))
97            })?;
98
99            // Validate required 'source' field
100            let source = secret_obj
101                .get("source")
102                .ok_or_else(|| {
103                    Error::Validation(format!(
104                        "Missing 'source' field in x-aperture-secret for security scheme '{name}'"
105                    ))
106                })?
107                .as_str()
108                .ok_or_else(|| {
109                    Error::Validation(format!(
110                        "Invalid 'source' field in x-aperture-secret for security scheme '{name}': must be a string"
111                    ))
112                })?;
113
114            // Currently only 'env' source is supported
115            if source != "env" {
116                return Err(Error::Validation(format!(
117                    "Unsupported source '{source}' in x-aperture-secret for security scheme '{name}'. Only 'env' is supported."
118                )));
119            }
120
121            // Validate required 'name' field
122            let env_name = secret_obj
123                .get("name")
124                .ok_or_else(|| {
125                    Error::Validation(format!(
126                        "Missing 'name' field in x-aperture-secret for security scheme '{name}'"
127                    ))
128                })?
129                .as_str()
130                .ok_or_else(|| {
131                    Error::Validation(format!(
132                        "Invalid 'name' field in x-aperture-secret for security scheme '{name}': must be a string"
133                    ))
134                })?;
135
136            // Validate environment variable name format
137            if env_name.is_empty() {
138                return Err(Error::Validation(format!(
139                    "Empty 'name' field in x-aperture-secret for security scheme '{name}'"
140                )));
141            }
142
143            // Check for valid environment variable name (alphanumeric and underscore, not starting with digit)
144            if !env_name.chars().all(|c| c.is_alphanumeric() || c == '_')
145                || env_name.chars().next().is_some_and(char::is_numeric)
146            {
147                return Err(Error::Validation(format!(
148                    "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."
149                )));
150            }
151        }
152
153        Ok(())
154    }
155
156    /// Validates an operation against Aperture's supported features
157    fn validate_operation(path: &str, method: &str, operation: &Operation) -> Result<(), Error> {
158        // Validate parameters
159        for param_ref in &operation.parameters {
160            match param_ref {
161                ReferenceOr::Item(param) => {
162                    Self::validate_parameter(path, method, param)?;
163                }
164                ReferenceOr::Reference { .. } => {
165                    return Err(Error::Validation(format!(
166                        "Parameter references are not supported in {method} {path}"
167                    )));
168                }
169            }
170        }
171
172        // Validate request body
173        if let Some(request_body_ref) = &operation.request_body {
174            match request_body_ref {
175                ReferenceOr::Item(request_body) => {
176                    Self::validate_request_body(path, method, request_body)?;
177                }
178                ReferenceOr::Reference { .. } => {
179                    return Err(Error::Validation(format!(
180                        "Request body references are not supported in {method} {path}."
181                    )));
182                }
183            }
184        }
185
186        Ok(())
187    }
188
189    /// Validates a parameter against Aperture's supported features
190    fn validate_parameter(path: &str, method: &str, param: &Parameter) -> Result<(), Error> {
191        let param_data = match param {
192            Parameter::Query { parameter_data, .. }
193            | Parameter::Header { parameter_data, .. }
194            | Parameter::Path { parameter_data, .. }
195            | Parameter::Cookie { parameter_data, .. } => parameter_data,
196        };
197
198        match &param_data.format {
199            openapiv3::ParameterSchemaOrContent::Schema(_) => Ok(()),
200            openapiv3::ParameterSchemaOrContent::Content(_) => {
201                Err(Error::Validation(format!(
202                    "Parameter '{}' in {method} {path} uses unsupported content-based serialization. Only schema-based parameters are supported.",
203                    param_data.name
204                )))
205            }
206        }
207    }
208
209    /// Validates a request body against Aperture's supported features
210    fn validate_request_body(
211        path: &str,
212        method: &str,
213        request_body: &RequestBody,
214    ) -> Result<(), Error> {
215        // Check for unsupported content types
216        for (content_type, _) in &request_body.content {
217            if content_type != "application/json" {
218                return Err(Error::Validation(format!(
219                    "Unsupported request body content type '{content_type}' in {method} {path}. Only 'application/json' is supported in v1.0."
220                )));
221            }
222        }
223
224        Ok(())
225    }
226}
227
228impl Default for SpecValidator {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use openapiv3::{Components, Info, OpenAPI};
238
239    fn create_test_spec() -> OpenAPI {
240        OpenAPI {
241            openapi: "3.0.0".to_string(),
242            info: Info {
243                title: "Test API".to_string(),
244                version: "1.0.0".to_string(),
245                ..Default::default()
246            },
247            ..Default::default()
248        }
249    }
250
251    #[test]
252    fn test_validate_empty_spec() {
253        let validator = SpecValidator::new();
254        let spec = create_test_spec();
255        assert!(validator.validate(&spec).is_ok());
256    }
257
258    #[test]
259    fn test_validate_oauth2_scheme_rejected() {
260        let validator = SpecValidator::new();
261        let mut spec = create_test_spec();
262        let mut components = Components::default();
263        components.security_schemes.insert(
264            "oauth".to_string(),
265            ReferenceOr::Item(SecurityScheme::OAuth2 {
266                flows: Default::default(),
267                description: None,
268                extensions: Default::default(),
269            }),
270        );
271        spec.components = Some(components);
272
273        let result = validator.validate(&spec);
274        assert!(result.is_err());
275        match result.unwrap_err() {
276            Error::Validation(msg) => {
277                assert!(msg.contains("OAuth2"));
278                assert!(msg.contains("not supported"));
279            }
280            _ => panic!("Expected Validation error"),
281        }
282    }
283
284    #[test]
285    fn test_validate_reference_rejected() {
286        let validator = SpecValidator::new();
287        let mut spec = create_test_spec();
288        let mut components = Components::default();
289        components.security_schemes.insert(
290            "auth".to_string(),
291            ReferenceOr::Reference {
292                reference: "#/components/securitySchemes/BasicAuth".to_string(),
293            },
294        );
295        spec.components = Some(components);
296
297        let result = validator.validate(&spec);
298        assert!(result.is_err());
299        match result.unwrap_err() {
300            Error::Validation(msg) => {
301                assert!(msg.contains("references are not supported"));
302            }
303            _ => panic!("Expected Validation error"),
304        }
305    }
306
307    #[test]
308    fn test_validate_supported_schemes() {
309        let validator = SpecValidator::new();
310        let mut spec = create_test_spec();
311        let mut components = Components::default();
312
313        // Add API key scheme
314        components.security_schemes.insert(
315            "apiKey".to_string(),
316            ReferenceOr::Item(SecurityScheme::APIKey {
317                location: openapiv3::APIKeyLocation::Header,
318                name: "X-API-Key".to_string(),
319                description: None,
320                extensions: Default::default(),
321            }),
322        );
323
324        // Add HTTP bearer scheme
325        components.security_schemes.insert(
326            "bearer".to_string(),
327            ReferenceOr::Item(SecurityScheme::HTTP {
328                scheme: "bearer".to_string(),
329                bearer_format: Some("JWT".to_string()),
330                description: None,
331                extensions: Default::default(),
332            }),
333        );
334
335        // Add HTTP basic scheme (now supported)
336        components.security_schemes.insert(
337            "basic".to_string(),
338            ReferenceOr::Item(SecurityScheme::HTTP {
339                scheme: "basic".to_string(),
340                bearer_format: None,
341                description: None,
342                extensions: Default::default(),
343            }),
344        );
345
346        spec.components = Some(components);
347
348        assert!(validator.validate(&spec).is_ok());
349    }
350
351    #[test]
352    fn test_validate_unsupported_http_scheme() {
353        let validator = SpecValidator::new();
354        let mut spec = create_test_spec();
355        let mut components = Components::default();
356
357        components.security_schemes.insert(
358            "digest".to_string(),
359            ReferenceOr::Item(SecurityScheme::HTTP {
360                scheme: "digest".to_string(),
361                bearer_format: None,
362                description: None,
363                extensions: Default::default(),
364            }),
365        );
366
367        spec.components = Some(components);
368
369        let result = validator.validate(&spec);
370        assert!(result.is_err());
371        match result.unwrap_err() {
372            Error::Validation(msg) => {
373                assert!(msg.contains("Unsupported HTTP scheme 'digest'"));
374            }
375            _ => panic!("Expected Validation error"),
376        }
377    }
378
379    #[test]
380    fn test_validate_parameter_reference_rejected() {
381        use openapiv3::{Operation, PathItem, ReferenceOr as PathRef, Responses};
382
383        let validator = SpecValidator::new();
384        let mut spec = create_test_spec();
385
386        let mut path_item = PathItem::default();
387        path_item.get = Some(Operation {
388            parameters: vec![ReferenceOr::Reference {
389                reference: "#/components/parameters/UserId".to_string(),
390            }],
391            responses: Responses::default(),
392            ..Default::default()
393        });
394
395        spec.paths
396            .paths
397            .insert("/users/{id}".to_string(), PathRef::Item(path_item));
398
399        let result = validator.validate(&spec);
400        assert!(result.is_err());
401        match result.unwrap_err() {
402            Error::Validation(msg) => {
403                assert!(msg.contains("Parameter references are not supported"));
404            }
405            _ => panic!("Expected Validation error"),
406        }
407    }
408
409    #[test]
410    fn test_validate_request_body_non_json_rejected() {
411        use openapiv3::{
412            MediaType, Operation, PathItem, ReferenceOr as PathRef, RequestBody, Responses,
413        };
414
415        let validator = SpecValidator::new();
416        let mut spec = create_test_spec();
417
418        let mut request_body = RequestBody::default();
419        request_body
420            .content
421            .insert("application/xml".to_string(), MediaType::default());
422        request_body.required = true;
423
424        let mut path_item = PathItem::default();
425        path_item.post = Some(Operation {
426            request_body: Some(ReferenceOr::Item(request_body)),
427            responses: Responses::default(),
428            ..Default::default()
429        });
430
431        spec.paths
432            .paths
433            .insert("/users".to_string(), PathRef::Item(path_item));
434
435        let result = validator.validate(&spec);
436        assert!(result.is_err());
437        match result.unwrap_err() {
438            Error::Validation(msg) => {
439                assert!(msg.contains("Unsupported request body content type 'application/xml'"));
440            }
441            _ => panic!("Expected Validation error"),
442        }
443    }
444
445    #[test]
446    fn test_validate_x_aperture_secret_valid() {
447        let validator = SpecValidator::new();
448        let mut spec = create_test_spec();
449        let mut components = Components::default();
450
451        // Create a bearer auth scheme with valid x-aperture-secret
452        let mut extensions = serde_json::Map::new();
453        extensions.insert(
454            "x-aperture-secret".to_string(),
455            serde_json::json!({
456                "source": "env",
457                "name": "API_TOKEN"
458            }),
459        );
460
461        components.security_schemes.insert(
462            "bearerAuth".to_string(),
463            ReferenceOr::Item(SecurityScheme::HTTP {
464                scheme: "bearer".to_string(),
465                bearer_format: None,
466                description: None,
467                extensions: extensions.into_iter().collect(),
468            }),
469        );
470        spec.components = Some(components);
471
472        assert!(validator.validate(&spec).is_ok());
473    }
474
475    #[test]
476    fn test_validate_x_aperture_secret_missing_source() {
477        let validator = SpecValidator::new();
478        let mut spec = create_test_spec();
479        let mut components = Components::default();
480
481        // Create a bearer auth scheme with invalid x-aperture-secret (missing source)
482        let mut extensions = serde_json::Map::new();
483        extensions.insert(
484            "x-aperture-secret".to_string(),
485            serde_json::json!({
486                "name": "API_TOKEN"
487            }),
488        );
489
490        components.security_schemes.insert(
491            "bearerAuth".to_string(),
492            ReferenceOr::Item(SecurityScheme::HTTP {
493                scheme: "bearer".to_string(),
494                bearer_format: None,
495                description: None,
496                extensions: extensions.into_iter().collect(),
497            }),
498        );
499        spec.components = Some(components);
500
501        let result = validator.validate(&spec);
502        assert!(result.is_err());
503        match result.unwrap_err() {
504            Error::Validation(msg) => {
505                assert!(msg.contains("Missing 'source' field"));
506            }
507            _ => panic!("Expected Validation error"),
508        }
509    }
510
511    #[test]
512    fn test_validate_x_aperture_secret_missing_name() {
513        let validator = SpecValidator::new();
514        let mut spec = create_test_spec();
515        let mut components = Components::default();
516
517        // Create a bearer auth scheme with invalid x-aperture-secret (missing name)
518        let mut extensions = serde_json::Map::new();
519        extensions.insert(
520            "x-aperture-secret".to_string(),
521            serde_json::json!({
522                "source": "env"
523            }),
524        );
525
526        components.security_schemes.insert(
527            "bearerAuth".to_string(),
528            ReferenceOr::Item(SecurityScheme::HTTP {
529                scheme: "bearer".to_string(),
530                bearer_format: None,
531                description: None,
532                extensions: extensions.into_iter().collect(),
533            }),
534        );
535        spec.components = Some(components);
536
537        let result = validator.validate(&spec);
538        assert!(result.is_err());
539        match result.unwrap_err() {
540            Error::Validation(msg) => {
541                assert!(msg.contains("Missing 'name' field"));
542            }
543            _ => panic!("Expected Validation error"),
544        }
545    }
546
547    #[test]
548    fn test_validate_x_aperture_secret_invalid_env_name() {
549        let validator = SpecValidator::new();
550        let mut spec = create_test_spec();
551        let mut components = Components::default();
552
553        // Create a bearer auth scheme with invalid environment variable name
554        let mut extensions = serde_json::Map::new();
555        extensions.insert(
556            "x-aperture-secret".to_string(),
557            serde_json::json!({
558                "source": "env",
559                "name": "123_INVALID"  // Starts with digit
560            }),
561        );
562
563        components.security_schemes.insert(
564            "bearerAuth".to_string(),
565            ReferenceOr::Item(SecurityScheme::HTTP {
566                scheme: "bearer".to_string(),
567                bearer_format: None,
568                description: None,
569                extensions: extensions.into_iter().collect(),
570            }),
571        );
572        spec.components = Some(components);
573
574        let result = validator.validate(&spec);
575        assert!(result.is_err());
576        match result.unwrap_err() {
577            Error::Validation(msg) => {
578                assert!(msg.contains("Invalid environment variable name"));
579            }
580            _ => panic!("Expected Validation error"),
581        }
582    }
583
584    #[test]
585    fn test_validate_x_aperture_secret_unsupported_source() {
586        let validator = SpecValidator::new();
587        let mut spec = create_test_spec();
588        let mut components = Components::default();
589
590        // Create a bearer auth scheme with unsupported source
591        let mut extensions = serde_json::Map::new();
592        extensions.insert(
593            "x-aperture-secret".to_string(),
594            serde_json::json!({
595                "source": "file",  // Not supported
596                "name": "API_TOKEN"
597            }),
598        );
599
600        components.security_schemes.insert(
601            "bearerAuth".to_string(),
602            ReferenceOr::Item(SecurityScheme::HTTP {
603                scheme: "bearer".to_string(),
604                bearer_format: None,
605                description: None,
606                extensions: extensions.into_iter().collect(),
607            }),
608        );
609        spec.components = Some(components);
610
611        let result = validator.validate(&spec);
612        assert!(result.is_err());
613        match result.unwrap_err() {
614            Error::Validation(msg) => {
615                assert!(msg.contains("Unsupported source 'file'"));
616            }
617            _ => panic!("Expected Validation error"),
618        }
619    }
620}