Skip to main content

mockforge_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::swagger_convert;
9use mockforge_foundation::error::{Error, Result};
10use openapiv3::{OpenAPI, ReferenceOr, Schema};
11use std::collections::HashSet;
12use std::path::Path;
13use tokio::fs;
14use tracing;
15
16/// HTTP methods that make a `paths.<path>.<key>` entry an Operation Object.
17const OPERATION_KEYS: [&str; 8] = [
18    "get", "put", "post", "delete", "options", "head", "patch", "trace",
19];
20
21/// Insert an empty `responses` object into any operation that lacks one,
22/// returning how many were repaired.
23///
24/// OpenAPI 3.x marks `responses` as REQUIRED on an Operation Object, and the
25/// `openapiv3` crate enforces that, so a spec missing it fails to deserialize
26/// with a bare `missing field \`responses\`` and no indication of WHERE. Specs
27/// emitted by proxies and API-discovery tools routinely omit it: they observe
28/// requests, so they know paths, methods and schemas, but have nothing to say
29/// about responses.
30///
31/// Refusing those specs outright is the wrong trade for this codebase. Request
32/// generation, load testing and conformance probing all derive from paths,
33/// parameters and `requestBody`; none of them read `responses`. So a missing
34/// `responses` costs nothing we actually use, while rejecting the document
35/// costs the user their entire run.
36///
37/// Reported by Srikanth on #79: a proxy-generated spec where 63 of 70
38/// operations had no `responses` failed to load at all.
39///
40/// The repair is deliberately narrow. It only fills in a field the spec says is
41/// mandatory, never invents response *content*, and leaves every other
42/// validation error to surface normally.
43fn fill_missing_operation_responses(raw: &mut serde_json::Value) -> usize {
44    let Some(paths) = raw.get_mut("paths").and_then(|p| p.as_object_mut()) else {
45        return 0;
46    };
47
48    let mut repaired = 0;
49    for (_path, item) in paths.iter_mut() {
50        let Some(item) = item.as_object_mut() else {
51            continue;
52        };
53        for method in OPERATION_KEYS {
54            let Some(op) = item.get_mut(method).and_then(|o| o.as_object_mut()) else {
55                continue;
56            };
57            if !op.contains_key("responses") {
58                op.insert(
59                    "responses".to_string(),
60                    serde_json::Value::Object(serde_json::Map::new()),
61                );
62                repaired += 1;
63            }
64        }
65    }
66    repaired
67}
68
69/// Apply [`fill_missing_operation_responses`] and warn once if anything was
70/// repaired, so a non-conformant spec is visible rather than silently accepted.
71fn repair_spec_for_parsing(raw: &mut serde_json::Value, source: &str) {
72    let repaired = fill_missing_operation_responses(raw);
73    if repaired > 0 {
74        tracing::warn!(
75            "{source}: {repaired} operation(s) had no `responses` field, which OpenAPI 3.x \
76             requires. Treating them as having no declared responses so the spec can load. \
77             Request generation does not use `responses`, but response-schema validation \
78             will have nothing to check for these operations."
79        );
80    }
81}
82
83/// OpenAPI specification loader and parser
84#[derive(Debug, Clone)]
85pub struct OpenApiSpec {
86    /// The parsed OpenAPI specification
87    pub spec: OpenAPI,
88    /// Path to the original spec file
89    pub file_path: Option<String>,
90    /// Raw OpenAPI document preserved as JSON for resolving unsupported constructs
91    pub raw_document: Option<serde_json::Value>,
92}
93
94impl OpenApiSpec {
95    /// Load OpenAPI spec from a file path
96    ///
97    /// Supports both OpenAPI 3.x and Swagger 2.0 specifications.
98    /// Swagger 2.0 specs are automatically converted to OpenAPI 3.0 format.
99    pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
100        let path_ref = path.as_ref();
101        let content = fs::read_to_string(path_ref)
102            .await
103            .map_err(|e| Error::io_with_context("reading OpenAPI spec file", e.to_string()))?;
104
105        let raw_json = if path_ref.extension().and_then(|s| s.to_str()) == Some("yaml")
106            || path_ref.extension().and_then(|s| s.to_str()) == Some("yml")
107        {
108            let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
109                .map_err(|e| Error::config(format!("Failed to parse YAML OpenAPI spec: {}", e)))?;
110            serde_json::to_value(&yaml_value).map_err(|e| {
111                Error::config(format!("Failed to convert YAML OpenAPI spec to JSON: {}", e))
112            })?
113        } else {
114            serde_json::from_str(&content)
115                .map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?
116        };
117
118        // Check if this is a Swagger 2.0 spec and convert if necessary
119        let (raw_document, spec) = if swagger_convert::is_swagger_2(&raw_json) {
120            tracing::info!("Detected Swagger 2.0 specification, converting to OpenAPI 3.0");
121            let converted =
122                swagger_convert::convert_swagger_to_openapi3(&raw_json).map_err(|e| {
123                    Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
124                })?;
125            let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
126                Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
127            })?;
128            (converted, spec)
129        } else {
130            let mut raw_json = raw_json;
131            repair_spec_for_parsing(&mut raw_json, "OpenAPI spec");
132            let spec: OpenAPI = serde_json::from_value(raw_json.clone()).map_err(|e| {
133                // Enhanced error reporting for debugging missing field errors
134                let error_str = format!("{}", e);
135                let mut error_msg = format!("Failed to read OpenAPI spec: {}", e);
136
137                // If it's a missing field error, add diagnostic information
138                if error_str.contains("missing field") {
139                    tracing::error!("OpenAPI deserialization error: {}", error_str);
140
141                    // Add context about the spec structure
142                    if let Some(info) = raw_json.get("info") {
143                        if let Some(info_obj) = info.as_object() {
144                            let has_desc = info_obj.contains_key("description");
145                            error_msg
146                                .push_str(&format!(" | Info.description present: {}", has_desc));
147                        }
148                    }
149                    if let Some(servers) = raw_json.get("servers") {
150                        if let Some(servers_arr) = servers.as_array() {
151                            error_msg.push_str(&format!(" | Servers count: {}", servers_arr.len()));
152                        }
153                    }
154                }
155
156                Error::config(error_msg)
157            })?;
158            (raw_json, spec)
159        };
160
161        Ok(Self {
162            spec,
163            file_path: path_ref.to_str().map(|s| s.to_string()),
164            raw_document: Some(raw_document),
165        })
166    }
167
168    /// Load OpenAPI spec from string content
169    ///
170    /// Supports both OpenAPI 3.x and Swagger 2.0 specifications.
171    /// Swagger 2.0 specs are automatically converted to OpenAPI 3.0 format.
172    pub fn from_string(content: &str, format: Option<&str>) -> Result<Self> {
173        let raw_json = if format == Some("yaml") || format == Some("yml") {
174            let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)
175                .map_err(|e| Error::config(format!("Failed to parse YAML OpenAPI spec: {}", e)))?;
176            serde_json::to_value(&yaml_value).map_err(|e| {
177                Error::config(format!("Failed to convert YAML OpenAPI spec to JSON: {}", e))
178            })?
179        } else {
180            serde_json::from_str(content)
181                .map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?
182        };
183
184        // Check if this is a Swagger 2.0 spec and convert if necessary
185        let (raw_document, spec) = if swagger_convert::is_swagger_2(&raw_json) {
186            let converted =
187                swagger_convert::convert_swagger_to_openapi3(&raw_json).map_err(|e| {
188                    Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
189                })?;
190            let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
191                Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
192            })?;
193            (converted, spec)
194        } else {
195            let mut raw_json = raw_json;
196            repair_spec_for_parsing(&mut raw_json, "OpenAPI spec");
197            let spec: OpenAPI = serde_json::from_value(raw_json.clone())
198                .map_err(|e| Error::io_with_context("reading OpenAPI spec", e.to_string()))?;
199            (raw_json, spec)
200        };
201
202        Ok(Self {
203            spec,
204            file_path: None,
205            raw_document: Some(raw_document),
206        })
207    }
208
209    /// Load OpenAPI spec from JSON value
210    ///
211    /// Supports both OpenAPI 3.x and Swagger 2.0 specifications.
212    /// Swagger 2.0 specs are automatically converted to OpenAPI 3.0 format.
213    pub fn from_json(json: serde_json::Value) -> Result<Self> {
214        // Check if this is a Swagger 2.0 spec and convert if necessary
215        let (raw_document, spec) = if swagger_convert::is_swagger_2(&json) {
216            let converted = swagger_convert::convert_swagger_to_openapi3(&json).map_err(|e| {
217                Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
218            })?;
219            let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
220                Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
221            })?;
222            (converted, spec)
223        } else {
224            let json_for_doc = json.clone();
225            let spec: OpenAPI = serde_json::from_value(json)
226                .map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?;
227            (json_for_doc, spec)
228        };
229
230        Ok(Self {
231            spec,
232            file_path: None,
233            raw_document: Some(raw_document),
234        })
235    }
236
237    /// Validate the OpenAPI specification
238    ///
239    /// This method provides basic validation. For comprehensive validation
240    /// with detailed error messages, use `spec_parser::OpenApiValidator::validate()`.
241    pub fn validate(&self) -> Result<()> {
242        // Basic validation - check that we have at least one path
243        if self.spec.paths.paths.is_empty() {
244            return Err(Error::validation("OpenAPI spec must contain at least one path"));
245        }
246
247        // Check that info section has required fields
248        if self.spec.info.title.is_empty() {
249            return Err(Error::validation("OpenAPI spec info must have a title"));
250        }
251
252        if self.spec.info.version.is_empty() {
253            return Err(Error::validation("OpenAPI spec info must have a version"));
254        }
255
256        Ok(())
257    }
258
259    /// Enhanced validation with detailed error reporting
260    pub fn validate_enhanced(&self) -> crate::spec_parser::ValidationResult {
261        // Convert to JSON value for enhanced validator
262        if let Some(raw) = &self.raw_document {
263            let format = if raw.get("swagger").is_some() {
264                crate::spec_parser::SpecFormat::OpenApi20
265            } else if let Some(version) = raw.get("openapi").and_then(|v| v.as_str()) {
266                if version.starts_with("3.1") {
267                    crate::spec_parser::SpecFormat::OpenApi31
268                } else {
269                    crate::spec_parser::SpecFormat::OpenApi30
270                }
271            } else {
272                // Default to 3.0 if we can't determine
273                crate::spec_parser::SpecFormat::OpenApi30
274            };
275            crate::spec_parser::OpenApiValidator::validate(raw, format)
276        } else {
277            // Fallback to basic validation if no raw document
278            crate::spec_parser::ValidationResult::failure(vec![
279                crate::spec_parser::ValidationError::new(
280                    "Cannot perform enhanced validation without raw document".to_string(),
281                ),
282            ])
283        }
284    }
285
286    /// Get the OpenAPI version
287    pub fn version(&self) -> &str {
288        &self.spec.openapi
289    }
290
291    /// Get the API title
292    pub fn title(&self) -> &str {
293        &self.spec.info.title
294    }
295
296    /// Get the API description
297    pub fn description(&self) -> Option<&str> {
298        self.spec.info.description.as_deref()
299    }
300
301    /// Get the API version
302    pub fn api_version(&self) -> &str {
303        &self.spec.info.version
304    }
305
306    /// Get the server URLs
307    pub fn servers(&self) -> &[openapiv3::Server] {
308        &self.spec.servers
309    }
310
311    /// Get all paths defined in the spec
312    pub fn paths(&self) -> &openapiv3::Paths {
313        &self.spec.paths
314    }
315
316    /// Get all schemas defined in the spec
317    pub fn schemas(&self) -> Option<&indexmap::IndexMap<String, ReferenceOr<Schema>>> {
318        self.spec.components.as_ref().map(|c| &c.schemas)
319    }
320
321    /// Get all security schemes defined in the spec
322    pub fn security_schemes(
323        &self,
324    ) -> Option<&indexmap::IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>>> {
325        self.spec.components.as_ref().map(|c| &c.security_schemes)
326    }
327
328    /// Get all operations for a given path
329    pub fn operations_for_path(
330        &self,
331        path: &str,
332    ) -> std::collections::HashMap<String, openapiv3::Operation> {
333        let mut operations = std::collections::HashMap::new();
334
335        if let Some(path_item_ref) = self.spec.paths.paths.get(path) {
336            // Handle the ReferenceOr<PathItem> case
337            if let Some(path_item) = path_item_ref.as_item() {
338                // Round 40 (#888 / #79) — Srikanth's Google Apigee
339                // spec puts the shared auth / format query
340                // parameters at PATH level, not on each operation.
341                // OpenAPI 3.0 §4.7.10.1: "Parameters that are
342                // included in the Operation Object inherit the
343                // parameters defined in the Path Item Object. If a
344                // parameter is already defined at the Path Item, the
345                // new definition will override it but can never
346                // remove it." We materialise that inheritance HERE
347                // (the lowest common point under both registry
348                // builders), so a request that violates a path-level
349                // `enum` or `type: boolean` reaches the validator's
350                // parameter loop instead of silently passing. We
351                // also resolve `$ref` parameters via
352                // `components.parameters` so the validator's loop
353                // (which skips `ReferenceOr::Reference` entries via
354                // `as_item()`) actually sees them.
355                let resolved_path_params: Vec<ReferenceOr<openapiv3::Parameter>> =
356                    path_item.parameters.iter().map(|p| self.resolve_parameter_ref(p)).collect();
357                let merge = |op: &openapiv3::Operation| -> openapiv3::Operation {
358                    // Resolve op-level refs too — same as path-level.
359                    let mut resolved_op = op.clone();
360                    resolved_op.parameters =
361                        op.parameters.iter().map(|p| self.resolve_parameter_ref(p)).collect();
362                    merge_path_params_into_operation(&resolved_op, &resolved_path_params)
363                };
364                if let Some(op) = &path_item.get {
365                    operations.insert("GET".to_string(), merge(op));
366                }
367                if let Some(op) = &path_item.post {
368                    operations.insert("POST".to_string(), merge(op));
369                }
370                if let Some(op) = &path_item.put {
371                    operations.insert("PUT".to_string(), merge(op));
372                }
373                if let Some(op) = &path_item.delete {
374                    operations.insert("DELETE".to_string(), merge(op));
375                }
376                if let Some(op) = &path_item.patch {
377                    operations.insert("PATCH".to_string(), merge(op));
378                }
379                if let Some(op) = &path_item.head {
380                    operations.insert("HEAD".to_string(), merge(op));
381                }
382                if let Some(op) = &path_item.options {
383                    operations.insert("OPTIONS".to_string(), merge(op));
384                }
385                if let Some(op) = &path_item.trace {
386                    operations.insert("TRACE".to_string(), merge(op));
387                }
388            }
389        }
390
391        operations
392    }
393
394    /// Get all paths with their operations
395    pub fn all_paths_and_operations(
396        &self,
397    ) -> std::collections::HashMap<String, std::collections::HashMap<String, openapiv3::Operation>>
398    {
399        self.spec
400            .paths
401            .paths
402            .iter()
403            .map(|(path, _)| (path.clone(), self.operations_for_path(path)))
404            .collect()
405    }
406
407    /// Get a schema by reference (returns wrapped OpenApiSchema)
408    pub fn get_schema(&self, reference: &str) -> Option<crate::schema::OpenApiSchema> {
409        self.resolve_schema(reference).map(crate::schema::OpenApiSchema::new)
410    }
411
412    /// Resolve a schema reference to the raw Schema
413    ///
414    /// This resolves `$ref` references like `#/components/schemas/User` to the
415    /// actual schema definition, handling nested references recursively.
416    pub fn resolve_schema_ref(&self, reference: &str) -> Option<Schema> {
417        self.resolve_schema(reference)
418    }
419
420    /// Round 40 (#888 / #79) — resolve a parameter `$ref` (typically
421    /// `#/components/parameters/foo`) into the inline `Parameter`
422    /// item it points at. Returns the input unchanged when the
423    /// reference can't be resolved (e.g. external `$ref`) so the
424    /// validator can fall back to its prior behaviour (skip via
425    /// `as_item()`) instead of panicking. Used by
426    /// `operations_for_path` to materialise refs at registry build
427    /// time, since the validator's parameter loop skips
428    /// `ReferenceOr::Reference` entries — which was why Srikanth's
429    /// Google Apigee spec silently passed every path-level param
430    /// violation: the path-level `parameters:` list is entirely
431    /// `$ref:` to shared common params like `_.xgafv`,
432    /// `prettyPrint`, etc.
433    pub fn resolve_parameter_ref(
434        &self,
435        p_ref: &ReferenceOr<openapiv3::Parameter>,
436    ) -> ReferenceOr<openapiv3::Parameter> {
437        match p_ref {
438            ReferenceOr::Item(_) => p_ref.clone(),
439            ReferenceOr::Reference { reference } => {
440                let Some(name) = reference.strip_prefix("#/components/parameters/") else {
441                    return p_ref.clone();
442                };
443                let Some(components) = self.spec.components.as_ref() else {
444                    return p_ref.clone();
445                };
446                match components.parameters.get(name) {
447                    Some(ReferenceOr::Item(p)) => ReferenceOr::Item(p.clone()),
448                    Some(ReferenceOr::Reference { reference: nested }) => {
449                        // Tail-resolve a chained ref (rare in practice
450                        // but allowed by the spec).
451                        let Some(nested_name) = nested.strip_prefix("#/components/parameters/")
452                        else {
453                            return p_ref.clone();
454                        };
455                        match components.parameters.get(nested_name) {
456                            Some(ReferenceOr::Item(p)) => ReferenceOr::Item(p.clone()),
457                            _ => p_ref.clone(),
458                        }
459                    }
460                    None => p_ref.clone(),
461                }
462            }
463        }
464    }
465
466    /// Validate security requirements
467    pub fn validate_security_requirements(
468        &self,
469        security_requirements: &[openapiv3::SecurityRequirement],
470        auth_header: Option<&str>,
471        api_key: Option<&str>,
472    ) -> Result<()> {
473        if security_requirements.is_empty() {
474            return Ok(());
475        }
476
477        // Security requirements are OR'd - if any requirement is satisfied, pass
478        for requirement in security_requirements {
479            if self.is_security_requirement_satisfied(requirement, auth_header, api_key)? {
480                return Ok(());
481            }
482        }
483
484        Err(Error::validation(
485            "Security validation failed: no valid authentication provided",
486        ))
487    }
488
489    fn resolve_schema(&self, reference: &str) -> Option<Schema> {
490        let mut visited = HashSet::new();
491        self.resolve_schema_recursive(reference, &mut visited)
492    }
493
494    fn resolve_schema_recursive(
495        &self,
496        reference: &str,
497        visited: &mut HashSet<String>,
498    ) -> Option<Schema> {
499        if !visited.insert(reference.to_string()) {
500            tracing::warn!("Detected recursive schema reference: {}", reference);
501            return None;
502        }
503
504        let schema_name = reference.strip_prefix("#/components/schemas/")?;
505        let components = self.spec.components.as_ref()?;
506        let schema_ref = components.schemas.get(schema_name)?;
507
508        match schema_ref {
509            ReferenceOr::Item(schema) => Some(schema.clone()),
510            ReferenceOr::Reference { reference: nested } => {
511                self.resolve_schema_recursive(nested, visited)
512            }
513        }
514    }
515
516    /// Check if a single security requirement is satisfied
517    fn is_security_requirement_satisfied(
518        &self,
519        requirement: &openapiv3::SecurityRequirement,
520        auth_header: Option<&str>,
521        api_key: Option<&str>,
522    ) -> Result<bool> {
523        // All schemes in the requirement must be satisfied (AND)
524        for (scheme_name, _scopes) in requirement {
525            if !self.is_security_scheme_satisfied(scheme_name, auth_header, api_key)? {
526                return Ok(false);
527            }
528        }
529        Ok(true)
530    }
531
532    /// Check if a security scheme is satisfied
533    fn is_security_scheme_satisfied(
534        &self,
535        scheme_name: &str,
536        auth_header: Option<&str>,
537        api_key: Option<&str>,
538    ) -> Result<bool> {
539        let security_schemes = match self.security_schemes() {
540            Some(schemes) => schemes,
541            None => return Ok(false),
542        };
543
544        let scheme = match security_schemes.get(scheme_name) {
545            Some(scheme) => scheme,
546            None => {
547                return Err(Error::config(format!("Security scheme '{}' not found", scheme_name)))
548            }
549        };
550
551        let scheme = match scheme {
552            ReferenceOr::Item(s) => s,
553            ReferenceOr::Reference { reference } => {
554                // Resolve $ref like "#/components/securitySchemes/BearerAuth"
555                let ref_name =
556                    reference.strip_prefix("#/components/securitySchemes/").ok_or_else(|| {
557                        Error::config(format!(
558                            "Unsupported security scheme reference format: {}",
559                            reference
560                        ))
561                    })?;
562                match security_schemes.get(ref_name) {
563                    Some(ReferenceOr::Item(resolved)) => resolved,
564                    Some(ReferenceOr::Reference { .. }) => {
565                        return Err(Error::config(format!(
566                            "Nested security scheme reference not supported: {}",
567                            ref_name
568                        )))
569                    }
570                    None => {
571                        return Err(Error::config(format!(
572                            "Security scheme '{}' not found",
573                            ref_name
574                        )))
575                    }
576                }
577            }
578        };
579
580        match scheme {
581            openapiv3::SecurityScheme::HTTP { scheme, .. } => {
582                match scheme.as_str() {
583                    "bearer" => match auth_header {
584                        Some(header) if header.starts_with("Bearer ") => Ok(true),
585                        _ => Ok(false),
586                    },
587                    "basic" => match auth_header {
588                        Some(header) if header.starts_with("Basic ") => Ok(true),
589                        _ => Ok(false),
590                    },
591                    _ => Ok(false), // Unsupported scheme
592                }
593            }
594            openapiv3::SecurityScheme::APIKey { location, .. } => match location {
595                openapiv3::APIKeyLocation::Header => Ok(auth_header.is_some()),
596                openapiv3::APIKeyLocation::Query => Ok(api_key.is_some()),
597                openapiv3::APIKeyLocation::Cookie => Ok(api_key.is_some()),
598            },
599            openapiv3::SecurityScheme::OpenIDConnect { .. } => {
600                // OpenID Connect uses Bearer tokens, same as OAuth2
601                match auth_header {
602                    Some(header) if header.starts_with("Bearer ") => Ok(true),
603                    _ => Ok(false),
604                }
605            }
606            openapiv3::SecurityScheme::OAuth2 { .. } => {
607                // For OAuth2, check if Bearer token is provided
608                match auth_header {
609                    Some(header) if header.starts_with("Bearer ") => Ok(true),
610                    _ => Ok(false),
611                }
612            }
613        }
614    }
615
616    /// Get global security requirements
617    pub fn get_global_security_requirements(&self) -> Vec<openapiv3::SecurityRequirement> {
618        self.spec.security.clone().unwrap_or_default()
619    }
620
621    /// Resolve a request body reference
622    pub fn get_request_body(&self, reference: &str) -> Option<&openapiv3::RequestBody> {
623        if let Some(components) = &self.spec.components {
624            if let Some(param_name) = reference.strip_prefix("#/components/requestBodies/") {
625                if let Some(request_body_ref) = components.request_bodies.get(param_name) {
626                    return request_body_ref.as_item();
627                }
628            }
629        }
630        None
631    }
632
633    /// Resolve a response reference
634    pub fn get_response(&self, reference: &str) -> Option<&openapiv3::Response> {
635        if let Some(components) = &self.spec.components {
636            if let Some(response_name) = reference.strip_prefix("#/components/responses/") {
637                if let Some(response_ref) = components.responses.get(response_name) {
638                    return response_ref.as_item();
639                }
640            }
641        }
642        None
643    }
644
645    /// Resolve an example reference
646    pub fn get_example(&self, reference: &str) -> Option<&openapiv3::Example> {
647        if let Some(components) = &self.spec.components {
648            if let Some(example_name) = reference.strip_prefix("#/components/examples/") {
649                if let Some(example_ref) = components.examples.get(example_name) {
650                    return example_ref.as_item();
651                }
652            }
653        }
654        None
655    }
656}
657
658/// Round 40 (#888 / #79) — merge path-level parameters from a
659/// `PathItem` into an `Operation`'s own parameters per OpenAPI 3.0
660/// §4.7.10.1. Returns a cloned `Operation` whose `parameters` list
661/// contains every path-level entry, followed by every operation-level
662/// entry, with collisions on `(name, in)` resolved in favour of the
663/// operation-level definition. The original `Operation` is not
664/// mutated. Lives in `spec.rs` so every registry builder that calls
665/// `operations_for_path` benefits from the merge automatically.
666pub(crate) fn merge_path_params_into_operation(
667    operation: &openapiv3::Operation,
668    path_level_params: &[ReferenceOr<openapiv3::Parameter>],
669) -> openapiv3::Operation {
670    use std::collections::HashSet;
671    if path_level_params.is_empty() {
672        return operation.clone();
673    }
674    let mut op_keys: HashSet<(String, String)> = HashSet::new();
675    for p_ref in &operation.parameters {
676        if let Some(key) = parameter_key(p_ref) {
677            op_keys.insert(key);
678        }
679    }
680    let mut merged: Vec<ReferenceOr<openapiv3::Parameter>> =
681        Vec::with_capacity(path_level_params.len() + operation.parameters.len());
682    for p_ref in path_level_params {
683        match parameter_key(p_ref) {
684            Some(key) if op_keys.contains(&key) => {}
685            _ => merged.push(p_ref.clone()),
686        }
687    }
688    merged.extend(operation.parameters.iter().cloned());
689    let mut cloned = operation.clone();
690    cloned.parameters = merged;
691    cloned
692}
693
694fn parameter_key(p_ref: &ReferenceOr<openapiv3::Parameter>) -> Option<(String, String)> {
695    let p = p_ref.as_item()?;
696    let (name, in_loc) = match p {
697        openapiv3::Parameter::Path { parameter_data, .. } => (parameter_data.name.clone(), "path"),
698        openapiv3::Parameter::Query { parameter_data, .. } => {
699            (parameter_data.name.clone(), "query")
700        }
701        openapiv3::Parameter::Header { parameter_data, .. } => {
702            (parameter_data.name.clone(), "header")
703        }
704        openapiv3::Parameter::Cookie { parameter_data, .. } => {
705            (parameter_data.name.clone(), "cookie")
706        }
707    };
708    Some((name, in_loc.to_string()))
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use openapiv3::{SchemaKind, Type};
715
716    #[test]
717    fn resolves_security_scheme_ref() {
718        let yaml = r#"
719openapi: 3.0.3
720info:
721  title: Test API
722  version: "1.0.0"
723paths:
724  /test:
725    get:
726      security:
727        - BearerRef: []
728      responses:
729        '200':
730          description: OK
731components:
732  securitySchemes:
733    BearerAuth:
734      type: http
735      scheme: bearer
736    BearerRef:
737      $ref: '#/components/securitySchemes/BearerAuth'
738        "#;
739
740        let spec = OpenApiSpec::from_string(yaml, Some("yaml")).expect("spec parses");
741
742        // Bearer token should satisfy the referenced scheme
743        let result = spec
744            .is_security_scheme_satisfied("BearerRef", Some("Bearer token123"), None)
745            .expect("should resolve ref");
746        assert!(result);
747
748        // Missing token should fail
749        let result = spec
750            .is_security_scheme_satisfied("BearerRef", None, None)
751            .expect("should resolve ref");
752        assert!(!result);
753    }
754
755    #[test]
756    fn resolves_nested_schema_references() {
757        let yaml = r#"
758openapi: 3.0.3
759info:
760  title: Test API
761  version: "1.0.0"
762paths: {}
763components:
764  schemas:
765    Apiary:
766      type: object
767      properties:
768        id:
769          type: string
770        hive:
771          $ref: '#/components/schemas/Hive'
772    Hive:
773      type: object
774      properties:
775        name:
776          type: string
777    HiveWrapper:
778      $ref: '#/components/schemas/Hive'
779        "#;
780
781        let spec = OpenApiSpec::from_string(yaml, Some("yaml")).expect("spec parses");
782
783        let apiary = spec.get_schema("#/components/schemas/Apiary").expect("resolve apiary schema");
784        assert!(matches!(apiary.schema.schema_kind, SchemaKind::Type(Type::Object(_))));
785
786        let wrapper = spec
787            .get_schema("#/components/schemas/HiveWrapper")
788            .expect("resolve wrapper schema");
789        assert!(matches!(wrapper.schema.schema_kind, SchemaKind::Type(Type::Object(_))));
790    }
791}
792
793#[cfg(test)]
794mod missing_responses_tests {
795    use super::*;
796
797    /// A proxy-generated spec with no `responses` on its operations must load.
798    /// Reported on #79: 63 of 70 operations lacked the field and the whole run
799    /// aborted with `missing field \`responses\``.
800    #[test]
801    fn spec_without_operation_responses_loads() {
802        let raw = r#"{
803            "openapi": "3.0.0",
804            "info": { "title": "proxy-generated", "version": "1.0.0" },
805            "paths": {
806                "/orders": {
807                    "get": { "summary": "list" },
808                    "post": {
809                        "summary": "create",
810                        "requestBody": {
811                            "content": { "application/json": { "schema": { "type": "object" } } }
812                        }
813                    }
814                }
815            }
816        }"#;
817
818        let spec = OpenApiSpec::from_string(raw, Some("json"))
819            .expect("a spec missing `responses` should still load for bench/load use");
820
821        let item = spec.spec.paths.paths.get("/orders").expect("path present");
822        let ReferenceOr::Item(item) = item else {
823            panic!("expected inline path item");
824        };
825        assert!(item.get.is_some(), "GET survived the repair");
826        assert!(item.post.is_some(), "POST survived the repair");
827        assert!(
828            item.post.as_ref().unwrap().request_body.is_some(),
829            "requestBody must be preserved — it is what request generation actually reads"
830        );
831    }
832
833    /// The repair must only add the mandatory field, never touch a spec that
834    /// already declares responses.
835    #[test]
836    fn existing_responses_are_left_alone() {
837        let mut raw: serde_json::Value = serde_json::from_str(
838            r#"{"paths": {"/a": {"get": {"responses": {"200": {"description": "ok"}}}}}}"#,
839        )
840        .unwrap();
841        let before = raw.clone();
842        assert_eq!(fill_missing_operation_responses(&mut raw), 0);
843        assert_eq!(raw, before, "a conformant spec must be byte-identical after the pass");
844    }
845
846    /// Non-operation keys under a path item (parameters, servers, $ref) must not
847    /// be mistaken for operations.
848    #[test]
849    fn non_operation_keys_are_not_touched() {
850        let mut raw: serde_json::Value = serde_json::from_str(
851            r#"{"paths": {"/a": {"parameters": [], "servers": [], "get": {}}}}"#,
852        )
853        .unwrap();
854        assert_eq!(fill_missing_operation_responses(&mut raw), 1, "only `get` is an operation");
855        let path = &raw["paths"]["/a"];
856        assert!(path["parameters"].is_array(), "parameters untouched");
857        assert!(path["servers"].is_array(), "servers untouched");
858        assert!(path["get"]["responses"].is_object(), "get repaired");
859    }
860}