mockforge_core/openapi/
spec.rs

1//! OpenAPI specification loading and parsing
2//!
3//! This module handles loading OpenAPI specifications from files,
4//! parsing them, and providing basic operations on the specs.
5//! It also supports Swagger 2.0 specifications by converting them
6//! to OpenAPI 3.0 format automatically.
7
8use crate::openapi::swagger_convert;
9use crate::{Error, Result};
10use openapiv3::{OpenAPI, ReferenceOr, Schema};
11use std::collections::HashSet;
12use std::path::Path;
13use tokio::fs;
14
15/// OpenAPI specification loader and parser
16#[derive(Debug, Clone)]
17pub struct OpenApiSpec {
18    /// The parsed OpenAPI specification
19    pub spec: OpenAPI,
20    /// Path to the original spec file
21    pub file_path: Option<String>,
22    /// Raw OpenAPI document preserved as JSON for resolving unsupported constructs
23    pub raw_document: Option<serde_json::Value>,
24}
25
26impl OpenApiSpec {
27    /// Load OpenAPI spec from a file path
28    ///
29    /// Supports both OpenAPI 3.x and Swagger 2.0 specifications.
30    /// Swagger 2.0 specs are automatically converted to OpenAPI 3.0 format.
31    pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
32        let path_ref = path.as_ref();
33        let content = fs::read_to_string(path_ref)
34            .await
35            .map_err(|e| Error::generic(format!("Failed to read OpenAPI spec file: {}", e)))?;
36
37        let raw_json = if path_ref.extension().and_then(|s| s.to_str()) == Some("yaml")
38            || path_ref.extension().and_then(|s| s.to_str()) == Some("yml")
39        {
40            let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
41                .map_err(|e| Error::generic(format!("Failed to parse YAML OpenAPI spec: {}", e)))?;
42            serde_json::to_value(&yaml_value).map_err(|e| {
43                Error::generic(format!("Failed to convert YAML OpenAPI spec to JSON: {}", e))
44            })?
45        } else {
46            serde_json::from_str(&content)
47                .map_err(|e| Error::generic(format!("Failed to parse JSON OpenAPI spec: {}", e)))?
48        };
49
50        // Check if this is a Swagger 2.0 spec and convert if necessary
51        let (raw_document, spec) = if swagger_convert::is_swagger_2(&raw_json) {
52            tracing::info!("Detected Swagger 2.0 specification, converting to OpenAPI 3.0");
53            let converted = swagger_convert::convert_swagger_to_openapi3(&raw_json)
54                .map_err(|e| Error::generic(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e)))?;
55            let spec: OpenAPI = serde_json::from_value(converted.clone())
56                .map_err(|e| Error::generic(format!("Failed to parse converted OpenAPI spec: {}", e)))?;
57            (converted, spec)
58        } else {
59            let spec: OpenAPI = serde_json::from_value(raw_json.clone())
60                .map_err(|e| Error::generic(format!("Failed to read OpenAPI spec: {}", e)))?;
61            (raw_json, spec)
62        };
63
64        Ok(Self {
65            spec,
66            file_path: path_ref.to_str().map(|s| s.to_string()),
67            raw_document: Some(raw_document),
68        })
69    }
70
71    /// Load OpenAPI spec from string content
72    ///
73    /// Supports both OpenAPI 3.x and Swagger 2.0 specifications.
74    /// Swagger 2.0 specs are automatically converted to OpenAPI 3.0 format.
75    pub fn from_string(content: &str, format: Option<&str>) -> Result<Self> {
76        let raw_json = if format == Some("yaml") || format == Some("yml") {
77            let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)
78                .map_err(|e| Error::generic(format!("Failed to parse YAML OpenAPI spec: {}", e)))?;
79            serde_json::to_value(&yaml_value).map_err(|e| {
80                Error::generic(format!("Failed to convert YAML OpenAPI spec to JSON: {}", e))
81            })?
82        } else {
83            serde_json::from_str(content)
84                .map_err(|e| Error::generic(format!("Failed to parse JSON OpenAPI spec: {}", e)))?
85        };
86
87        // Check if this is a Swagger 2.0 spec and convert if necessary
88        let (raw_document, spec) = if swagger_convert::is_swagger_2(&raw_json) {
89            let converted = swagger_convert::convert_swagger_to_openapi3(&raw_json)
90                .map_err(|e| Error::generic(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e)))?;
91            let spec: OpenAPI = serde_json::from_value(converted.clone())
92                .map_err(|e| Error::generic(format!("Failed to parse converted OpenAPI spec: {}", e)))?;
93            (converted, spec)
94        } else {
95            let spec: OpenAPI = serde_json::from_value(raw_json.clone())
96                .map_err(|e| Error::generic(format!("Failed to read OpenAPI spec: {}", e)))?;
97            (raw_json, spec)
98        };
99
100        Ok(Self {
101            spec,
102            file_path: None,
103            raw_document: Some(raw_document),
104        })
105    }
106
107    /// Load OpenAPI spec from JSON value
108    ///
109    /// Supports both OpenAPI 3.x and Swagger 2.0 specifications.
110    /// Swagger 2.0 specs are automatically converted to OpenAPI 3.0 format.
111    pub fn from_json(json: serde_json::Value) -> Result<Self> {
112        // Check if this is a Swagger 2.0 spec and convert if necessary
113        let (raw_document, spec) = if swagger_convert::is_swagger_2(&json) {
114            let converted = swagger_convert::convert_swagger_to_openapi3(&json)
115                .map_err(|e| Error::generic(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e)))?;
116            let spec: OpenAPI = serde_json::from_value(converted.clone())
117                .map_err(|e| Error::generic(format!("Failed to parse converted OpenAPI spec: {}", e)))?;
118            (converted, spec)
119        } else {
120            let json_for_doc = json.clone();
121            let spec: OpenAPI = serde_json::from_value(json)
122                .map_err(|e| Error::generic(format!("Failed to parse JSON OpenAPI spec: {}", e)))?;
123            (json_for_doc, spec)
124        };
125
126        Ok(Self {
127            spec,
128            file_path: None,
129            raw_document: Some(raw_document),
130        })
131    }
132
133    /// Validate the OpenAPI specification
134    ///
135    /// This method provides basic validation. For comprehensive validation
136    /// with detailed error messages, use `spec_parser::OpenApiValidator::validate()`.
137    pub fn validate(&self) -> Result<()> {
138        // Basic validation - check that we have at least one path
139        if self.spec.paths.paths.is_empty() {
140            return Err(Error::generic("OpenAPI spec must contain at least one path"));
141        }
142
143        // Check that info section has required fields
144        if self.spec.info.title.is_empty() {
145            return Err(Error::generic("OpenAPI spec info must have a title"));
146        }
147
148        if self.spec.info.version.is_empty() {
149            return Err(Error::generic("OpenAPI spec info must have a version"));
150        }
151
152        Ok(())
153    }
154
155    /// Enhanced validation with detailed error reporting
156    pub fn validate_enhanced(&self) -> crate::spec_parser::ValidationResult {
157        // Convert to JSON value for enhanced validator
158        if let Some(raw) = &self.raw_document {
159            let format = if raw.get("swagger").is_some() {
160                crate::spec_parser::SpecFormat::OpenApi20
161            } else if let Some(version) = raw.get("openapi").and_then(|v| v.as_str()) {
162                if version.starts_with("3.1") {
163                    crate::spec_parser::SpecFormat::OpenApi31
164                } else {
165                    crate::spec_parser::SpecFormat::OpenApi30
166                }
167            } else {
168                // Default to 3.0 if we can't determine
169                crate::spec_parser::SpecFormat::OpenApi30
170            };
171            crate::spec_parser::OpenApiValidator::validate(raw, format)
172        } else {
173            // Fallback to basic validation if no raw document
174            crate::spec_parser::ValidationResult::failure(vec![
175                crate::spec_parser::ValidationError::new(
176                    "Cannot perform enhanced validation without raw document".to_string(),
177                ),
178            ])
179        }
180    }
181
182    /// Get the OpenAPI version
183    pub fn version(&self) -> &str {
184        &self.spec.openapi
185    }
186
187    /// Get the API title
188    pub fn title(&self) -> &str {
189        &self.spec.info.title
190    }
191
192    /// Get the API description
193    pub fn description(&self) -> Option<&str> {
194        self.spec.info.description.as_deref()
195    }
196
197    /// Get the API version
198    pub fn api_version(&self) -> &str {
199        &self.spec.info.version
200    }
201
202    /// Get the server URLs
203    pub fn servers(&self) -> &[openapiv3::Server] {
204        &self.spec.servers
205    }
206
207    /// Get all paths defined in the spec
208    pub fn paths(&self) -> &openapiv3::Paths {
209        &self.spec.paths
210    }
211
212    /// Get all schemas defined in the spec
213    pub fn schemas(
214        &self,
215    ) -> Option<&indexmap::IndexMap<String, openapiv3::ReferenceOr<openapiv3::Schema>>> {
216        self.spec.components.as_ref().map(|c| &c.schemas)
217    }
218
219    /// Get all security schemes defined in the spec
220    pub fn security_schemes(
221        &self,
222    ) -> Option<&indexmap::IndexMap<String, openapiv3::ReferenceOr<openapiv3::SecurityScheme>>>
223    {
224        self.spec.components.as_ref().map(|c| &c.security_schemes)
225    }
226
227    /// Get all operations for a given path
228    pub fn operations_for_path(
229        &self,
230        path: &str,
231    ) -> std::collections::HashMap<String, openapiv3::Operation> {
232        let mut operations = std::collections::HashMap::new();
233
234        if let Some(path_item_ref) = self.spec.paths.paths.get(path) {
235            // Handle the ReferenceOr<PathItem> case
236            if let Some(path_item) = path_item_ref.as_item() {
237                if let Some(op) = &path_item.get {
238                    operations.insert("GET".to_string(), op.clone());
239                }
240                if let Some(op) = &path_item.post {
241                    operations.insert("POST".to_string(), op.clone());
242                }
243                if let Some(op) = &path_item.put {
244                    operations.insert("PUT".to_string(), op.clone());
245                }
246                if let Some(op) = &path_item.delete {
247                    operations.insert("DELETE".to_string(), op.clone());
248                }
249                if let Some(op) = &path_item.patch {
250                    operations.insert("PATCH".to_string(), op.clone());
251                }
252                if let Some(op) = &path_item.head {
253                    operations.insert("HEAD".to_string(), op.clone());
254                }
255                if let Some(op) = &path_item.options {
256                    operations.insert("OPTIONS".to_string(), op.clone());
257                }
258                if let Some(op) = &path_item.trace {
259                    operations.insert("TRACE".to_string(), op.clone());
260                }
261            }
262        }
263
264        operations
265    }
266
267    /// Get all paths with their operations
268    pub fn all_paths_and_operations(
269        &self,
270    ) -> std::collections::HashMap<String, std::collections::HashMap<String, openapiv3::Operation>>
271    {
272        self.spec
273            .paths
274            .paths
275            .iter()
276            .map(|(path, _)| (path.clone(), self.operations_for_path(path)))
277            .collect()
278    }
279
280    /// Get a schema by reference
281    pub fn get_schema(&self, reference: &str) -> Option<crate::openapi::schema::OpenApiSchema> {
282        self.resolve_schema(reference).map(crate::openapi::schema::OpenApiSchema::new)
283    }
284
285    /// Validate security requirements
286    pub fn validate_security_requirements(
287        &self,
288        security_requirements: &[openapiv3::SecurityRequirement],
289        auth_header: Option<&str>,
290        api_key: Option<&str>,
291    ) -> Result<()> {
292        if security_requirements.is_empty() {
293            return Ok(());
294        }
295
296        // Security requirements are OR'd - if any requirement is satisfied, pass
297        for requirement in security_requirements {
298            if self.is_security_requirement_satisfied(requirement, auth_header, api_key)? {
299                return Ok(());
300            }
301        }
302
303        Err(Error::generic("Security validation failed: no valid authentication provided"))
304    }
305
306    fn resolve_schema(&self, reference: &str) -> Option<Schema> {
307        let mut visited = HashSet::new();
308        self.resolve_schema_recursive(reference, &mut visited)
309    }
310
311    fn resolve_schema_recursive(
312        &self,
313        reference: &str,
314        visited: &mut HashSet<String>,
315    ) -> Option<Schema> {
316        if !visited.insert(reference.to_string()) {
317            tracing::warn!("Detected recursive schema reference: {}", reference);
318            return None;
319        }
320
321        let schema_name = reference.strip_prefix("#/components/schemas/")?;
322        let components = self.spec.components.as_ref()?;
323        let schema_ref = components.schemas.get(schema_name)?;
324
325        match schema_ref {
326            ReferenceOr::Item(schema) => Some(schema.clone()),
327            ReferenceOr::Reference { reference: nested } => {
328                self.resolve_schema_recursive(nested, visited)
329            }
330        }
331    }
332
333    /// Check if a single security requirement is satisfied
334    fn is_security_requirement_satisfied(
335        &self,
336        requirement: &openapiv3::SecurityRequirement,
337        auth_header: Option<&str>,
338        api_key: Option<&str>,
339    ) -> Result<bool> {
340        // All schemes in the requirement must be satisfied (AND)
341        for (scheme_name, _scopes) in requirement {
342            if !self.is_security_scheme_satisfied(scheme_name, auth_header, api_key)? {
343                return Ok(false);
344            }
345        }
346        Ok(true)
347    }
348
349    /// Check if a security scheme is satisfied
350    fn is_security_scheme_satisfied(
351        &self,
352        scheme_name: &str,
353        auth_header: Option<&str>,
354        api_key: Option<&str>,
355    ) -> Result<bool> {
356        let security_schemes = match self.security_schemes() {
357            Some(schemes) => schemes,
358            None => return Ok(false),
359        };
360
361        let scheme = match security_schemes.get(scheme_name) {
362            Some(scheme) => scheme,
363            None => {
364                return Err(Error::generic(format!("Security scheme '{}' not found", scheme_name)))
365            }
366        };
367
368        let scheme = match scheme {
369            openapiv3::ReferenceOr::Item(s) => s,
370            openapiv3::ReferenceOr::Reference { .. } => {
371                return Err(Error::generic("Referenced security schemes not supported"))
372            }
373        };
374
375        match scheme {
376            openapiv3::SecurityScheme::HTTP { scheme, .. } => {
377                match scheme.as_str() {
378                    "bearer" => match auth_header {
379                        Some(header) if header.starts_with("Bearer ") => Ok(true),
380                        _ => Ok(false),
381                    },
382                    "basic" => match auth_header {
383                        Some(header) if header.starts_with("Basic ") => Ok(true),
384                        _ => Ok(false),
385                    },
386                    _ => Ok(false), // Unsupported scheme
387                }
388            }
389            openapiv3::SecurityScheme::APIKey { location, .. } => {
390                match location {
391                    openapiv3::APIKeyLocation::Header => Ok(auth_header.is_some()),
392                    openapiv3::APIKeyLocation::Query => Ok(api_key.is_some()),
393                    _ => Ok(false), // Cookie not supported
394                }
395            }
396            openapiv3::SecurityScheme::OpenIDConnect { .. } => Ok(false), // Not implemented
397            openapiv3::SecurityScheme::OAuth2 { .. } => {
398                // For OAuth2, check if Bearer token is provided
399                match auth_header {
400                    Some(header) if header.starts_with("Bearer ") => Ok(true),
401                    _ => Ok(false),
402                }
403            }
404        }
405    }
406
407    /// Get global security requirements
408    pub fn get_global_security_requirements(&self) -> Vec<openapiv3::SecurityRequirement> {
409        self.spec.security.clone().unwrap_or_default()
410    }
411
412    /// Resolve a request body reference
413    pub fn get_request_body(&self, reference: &str) -> Option<&openapiv3::RequestBody> {
414        if let Some(components) = &self.spec.components {
415            if let Some(param_name) = reference.strip_prefix("#/components/requestBodies/") {
416                if let Some(request_body_ref) = components.request_bodies.get(param_name) {
417                    return request_body_ref.as_item();
418                }
419            }
420        }
421        None
422    }
423
424    /// Resolve a response reference
425    pub fn get_response(&self, reference: &str) -> Option<&openapiv3::Response> {
426        if let Some(components) = &self.spec.components {
427            if let Some(response_name) = reference.strip_prefix("#/components/responses/") {
428                if let Some(response_ref) = components.responses.get(response_name) {
429                    return response_ref.as_item();
430                }
431            }
432        }
433        None
434    }
435
436    /// Resolve an example reference
437    pub fn get_example(&self, reference: &str) -> Option<&openapiv3::Example> {
438        if let Some(components) = &self.spec.components {
439            if let Some(example_name) = reference.strip_prefix("#/components/examples/") {
440                if let Some(example_ref) = components.examples.get(example_name) {
441                    return example_ref.as_item();
442                }
443            }
444        }
445        None
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use openapiv3::{SchemaKind, Type};
453
454    #[test]
455    fn resolves_nested_schema_references() {
456        let yaml = r#"
457openapi: 3.0.3
458info:
459  title: Test API
460  version: "1.0.0"
461paths: {}
462components:
463  schemas:
464    Apiary:
465      type: object
466      properties:
467        id:
468          type: string
469        hive:
470          $ref: '#/components/schemas/Hive'
471    Hive:
472      type: object
473      properties:
474        name:
475          type: string
476    HiveWrapper:
477      $ref: '#/components/schemas/Hive'
478        "#;
479
480        let spec = OpenApiSpec::from_string(yaml, Some("yaml")).expect("spec parses");
481
482        let apiary = spec.get_schema("#/components/schemas/Apiary").expect("resolve apiary schema");
483        assert!(matches!(apiary.schema.schema_kind, SchemaKind::Type(Type::Object(_))));
484
485        let wrapper = spec
486            .get_schema("#/components/schemas/HiveWrapper")
487            .expect("resolve wrapper schema");
488        assert!(matches!(wrapper.schema.schema_kind, SchemaKind::Type(Type::Object(_))));
489    }
490}