1use crate::error::Error;
2use openapiv3::{OpenAPI, Operation, Parameter, ReferenceOr, RequestBody, SecurityScheme};
3
4pub struct SpecValidator;
6
7impl SpecValidator {
8 #[must_use]
10 pub const fn new() -> Self {
11 Self
12 }
13
14 pub fn validate(&self, spec: &OpenAPI) -> Result<(), Error> {
25 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 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 fn validate_security_scheme(name: &str, scheme: &SecurityScheme) -> Result<(), Error> {
57 match scheme {
59 SecurityScheme::APIKey { .. } => {
60 }
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 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 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 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 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 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 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 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 fn validate_operation(path: &str, method: &str, operation: &Operation) -> Result<(), Error> {
158 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 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 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 ¶m_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 fn validate_request_body(
211 path: &str,
212 method: &str,
213 request_body: &RequestBody,
214 ) -> Result<(), Error> {
215 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 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 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 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 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 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 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 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" }),
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 let mut extensions = serde_json::Map::new();
592 extensions.insert(
593 "x-aperture-secret".to_string(),
594 serde_json::json!({
595 "source": "file", "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}