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/// JSON Schema primitive names that OAS 3.1 may put in a `type` array.
70/// Anything else under a key named `type` is left alone (security schemes
71/// use a string; vendor junk should not be rewritten).
72const JSON_SCHEMA_TYPE_NAMES: &[&str] = &[
73    "null", "boolean", "object", "array", "number", "string", "integer",
74];
75
76fn is_json_schema_type_name(name: &str) -> bool {
77    JSON_SCHEMA_TYPE_NAMES.contains(&name)
78}
79
80/// Convert OAS 3.1 JSON Schema `type` arrays into OAS 3.0 `type` + `nullable`.
81///
82/// `openapiv3` deserializes schema `type` as a string. OAS 3.1 (JSON Schema
83/// 2020-12) allows `type: ["string", "null"]`. LLM-generated specs do this
84/// constantly. Serde then fails with `invalid type: sequence, expected a
85/// string` and no JSON path, so a 1.8MB document is unusable.
86///
87/// Reported by Srikanth on #79 (i): `custom_limit_oas.json` (openapi 3.1.0)
88/// had exactly two such arrays (`taxId`, `nickname`) and `mockforge bench`
89/// died before extracting any of its 1750 operations.
90///
91/// The repair is narrow:
92/// - only rewrite `type` when every array element is a JSON Schema type name
93/// - `["string", "null"]` (either order) becomes `type: "string"` plus
94///   `nullable: true`
95/// - `["string"]` becomes `type: "string"`
96/// - a union of two non-null primitives takes the first; `openapiv3` cannot
97///   represent that union, and failing the whole spec is worse
98/// - `required: ["a", "b"]` and other string arrays are not `type`, so they
99///   are not touched
100fn coerce_json_schema_type_arrays(raw: &mut serde_json::Value) -> usize {
101    fn walk(value: &mut serde_json::Value) -> usize {
102        let mut repaired = 0;
103        match value {
104            serde_json::Value::Object(map) => {
105                repaired += coerce_type_array_in_object(map);
106                for child in map.values_mut() {
107                    repaired += walk(child);
108                }
109            }
110            serde_json::Value::Array(items) => {
111                for child in items {
112                    repaired += walk(child);
113                }
114            }
115            _ => {}
116        }
117        repaired
118    }
119    walk(raw)
120}
121
122fn coerce_type_array_in_object(map: &mut serde_json::Map<String, serde_json::Value>) -> usize {
123    let Some(serde_json::Value::Array(items)) = map.get("type") else {
124        return 0;
125    };
126    if items.is_empty() {
127        return 0;
128    }
129
130    // Reject anything that is not a JSON Schema type-name array. A `required`
131    // list lives under a different key; this guard is for a `type` that
132    // happens to be an array of something else.
133    let mut names = Vec::with_capacity(items.len());
134    for item in items {
135        let Some(name) = item.as_str() else {
136            return 0;
137        };
138        if !is_json_schema_type_name(name) {
139            return 0;
140        }
141        names.push(name.to_string());
142    }
143
144    let nullable = names.iter().any(|n| n == "null");
145    let mut non_null = names.into_iter().filter(|n| n != "null");
146    // `type: ["null"]` has no remaining primitive. `string` is the least-wrong
147    // stand-in so the document still deserializes.
148    let primary = non_null.next().unwrap_or_else(|| "string".to_string());
149
150    map.insert("type".to_string(), serde_json::Value::String(primary));
151    if nullable {
152        // OAS 3.0 equivalent of including "null" in a 3.1 type union.
153        map.insert("nullable".to_string(), serde_json::Value::Bool(true));
154    }
155    1
156}
157
158/// Apply [`fill_missing_operation_responses`] and
159/// [`coerce_json_schema_type_arrays`], warning once per kind so a
160/// non-conformant spec is visible rather than silently accepted.
161fn repair_spec_for_parsing(raw: &mut serde_json::Value, source: &str) {
162    let repaired = fill_missing_operation_responses(raw);
163    if repaired > 0 {
164        tracing::warn!(
165            "{source}: {repaired} operation(s) had no `responses` field, which OpenAPI 3.x \
166             requires. Treating them as having no declared responses so the spec can load. \
167             Request generation does not use `responses`, but response-schema validation \
168             will have nothing to check for these operations."
169        );
170    }
171
172    let type_arrays = coerce_json_schema_type_arrays(raw);
173    if type_arrays > 0 {
174        tracing::warn!(
175            "{source}: {type_arrays} schema `type` value(s) were OpenAPI 3.1 type arrays \
176             (for example `[\"string\", \"null\"]`). Coerced them to OpenAPI 3.0 `type` + \
177             `nullable` so the spec can load."
178        );
179    }
180}
181
182/// OpenAPI specification loader and parser
183#[derive(Debug, Clone)]
184pub struct OpenApiSpec {
185    /// The parsed OpenAPI specification
186    pub spec: OpenAPI,
187    /// Path to the original spec file
188    pub file_path: Option<String>,
189    /// Raw OpenAPI document preserved as JSON for resolving unsupported constructs
190    pub raw_document: Option<serde_json::Value>,
191}
192
193impl OpenApiSpec {
194    /// Load OpenAPI spec from a file path
195    ///
196    /// Supports both OpenAPI 3.x and Swagger 2.0 specifications.
197    /// Swagger 2.0 specs are automatically converted to OpenAPI 3.0 format.
198    pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
199        let path_ref = path.as_ref();
200        let content = fs::read_to_string(path_ref)
201            .await
202            .map_err(|e| Error::io_with_context("reading OpenAPI spec file", e.to_string()))?;
203
204        let raw_json = if path_ref.extension().and_then(|s| s.to_str()) == Some("yaml")
205            || path_ref.extension().and_then(|s| s.to_str()) == Some("yml")
206        {
207            let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
208                .map_err(|e| Error::config(format!("Failed to parse YAML OpenAPI spec: {}", e)))?;
209            serde_json::to_value(&yaml_value).map_err(|e| {
210                Error::config(format!("Failed to convert YAML OpenAPI spec to JSON: {}", e))
211            })?
212        } else {
213            serde_json::from_str(&content)
214                .map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?
215        };
216
217        // Check if this is a Swagger 2.0 spec and convert if necessary
218        let (raw_document, spec) = if swagger_convert::is_swagger_2(&raw_json) {
219            tracing::info!("Detected Swagger 2.0 specification, converting to OpenAPI 3.0");
220            let converted =
221                swagger_convert::convert_swagger_to_openapi3(&raw_json).map_err(|e| {
222                    Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
223                })?;
224            let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
225                Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
226            })?;
227            (converted, spec)
228        } else {
229            let mut raw_json = raw_json;
230            repair_spec_for_parsing(&mut raw_json, "OpenAPI spec");
231            let spec: OpenAPI = serde_json::from_value(raw_json.clone()).map_err(|e| {
232                // Enhanced error reporting for debugging missing field errors
233                let error_str = format!("{}", e);
234                let mut error_msg = format!("Failed to read OpenAPI spec: {}", e);
235
236                // If it's a missing field error, add diagnostic information
237                if error_str.contains("missing field") {
238                    tracing::error!("OpenAPI deserialization error: {}", error_str);
239
240                    // Add context about the spec structure
241                    if let Some(info) = raw_json.get("info") {
242                        if let Some(info_obj) = info.as_object() {
243                            let has_desc = info_obj.contains_key("description");
244                            error_msg
245                                .push_str(&format!(" | Info.description present: {}", has_desc));
246                        }
247                    }
248                    if let Some(servers) = raw_json.get("servers") {
249                        if let Some(servers_arr) = servers.as_array() {
250                            error_msg.push_str(&format!(" | Servers count: {}", servers_arr.len()));
251                        }
252                    }
253                }
254
255                Error::config(error_msg)
256            })?;
257            (raw_json, spec)
258        };
259
260        Ok(Self {
261            spec,
262            file_path: path_ref.to_str().map(|s| s.to_string()),
263            raw_document: Some(raw_document),
264        })
265    }
266
267    /// Load OpenAPI spec from string content
268    ///
269    /// Supports both OpenAPI 3.x and Swagger 2.0 specifications.
270    /// Swagger 2.0 specs are automatically converted to OpenAPI 3.0 format.
271    pub fn from_string(content: &str, format: Option<&str>) -> Result<Self> {
272        let raw_json = if format == Some("yaml") || format == Some("yml") {
273            let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)
274                .map_err(|e| Error::config(format!("Failed to parse YAML OpenAPI spec: {}", e)))?;
275            serde_json::to_value(&yaml_value).map_err(|e| {
276                Error::config(format!("Failed to convert YAML OpenAPI spec to JSON: {}", e))
277            })?
278        } else {
279            serde_json::from_str(content)
280                .map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?
281        };
282
283        // Check if this is a Swagger 2.0 spec and convert if necessary
284        let (raw_document, spec) = if swagger_convert::is_swagger_2(&raw_json) {
285            let converted =
286                swagger_convert::convert_swagger_to_openapi3(&raw_json).map_err(|e| {
287                    Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
288                })?;
289            let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
290                Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
291            })?;
292            (converted, spec)
293        } else {
294            let mut raw_json = raw_json;
295            repair_spec_for_parsing(&mut raw_json, "OpenAPI spec");
296            let spec: OpenAPI = serde_json::from_value(raw_json.clone())
297                .map_err(|e| Error::io_with_context("reading OpenAPI spec", e.to_string()))?;
298            (raw_json, spec)
299        };
300
301        Ok(Self {
302            spec,
303            file_path: None,
304            raw_document: Some(raw_document),
305        })
306    }
307
308    /// Load OpenAPI spec from JSON value
309    ///
310    /// Supports both OpenAPI 3.x and Swagger 2.0 specifications.
311    /// Swagger 2.0 specs are automatically converted to OpenAPI 3.0 format.
312    pub fn from_json(json: serde_json::Value) -> Result<Self> {
313        // Check if this is a Swagger 2.0 spec and convert if necessary
314        let (raw_document, spec) = if swagger_convert::is_swagger_2(&json) {
315            let converted = swagger_convert::convert_swagger_to_openapi3(&json).map_err(|e| {
316                Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
317            })?;
318            let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
319                Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
320            })?;
321            (converted, spec)
322        } else {
323            let mut json = json;
324            repair_spec_for_parsing(&mut json, "OpenAPI spec");
325            let spec: OpenAPI = serde_json::from_value(json.clone())
326                .map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?;
327            (json, spec)
328        };
329
330        Ok(Self {
331            spec,
332            file_path: None,
333            raw_document: Some(raw_document),
334        })
335    }
336
337    /// Validate the OpenAPI specification
338    ///
339    /// This method provides basic validation. For comprehensive validation
340    /// with detailed error messages, use `spec_parser::OpenApiValidator::validate()`.
341    pub fn validate(&self) -> Result<()> {
342        // Basic validation - check that we have at least one path
343        if self.spec.paths.paths.is_empty() {
344            return Err(Error::validation("OpenAPI spec must contain at least one path"));
345        }
346
347        // Check that info section has required fields
348        if self.spec.info.title.is_empty() {
349            return Err(Error::validation("OpenAPI spec info must have a title"));
350        }
351
352        if self.spec.info.version.is_empty() {
353            return Err(Error::validation("OpenAPI spec info must have a version"));
354        }
355
356        Ok(())
357    }
358
359    /// Enhanced validation with detailed error reporting
360    pub fn validate_enhanced(&self) -> crate::spec_parser::ValidationResult {
361        // Convert to JSON value for enhanced validator
362        if let Some(raw) = &self.raw_document {
363            let format = if raw.get("swagger").is_some() {
364                crate::spec_parser::SpecFormat::OpenApi20
365            } else if let Some(version) = raw.get("openapi").and_then(|v| v.as_str()) {
366                if version.starts_with("3.1") {
367                    crate::spec_parser::SpecFormat::OpenApi31
368                } else {
369                    crate::spec_parser::SpecFormat::OpenApi30
370                }
371            } else {
372                // Default to 3.0 if we can't determine
373                crate::spec_parser::SpecFormat::OpenApi30
374            };
375            crate::spec_parser::OpenApiValidator::validate(raw, format)
376        } else {
377            // Fallback to basic validation if no raw document
378            crate::spec_parser::ValidationResult::failure(vec![
379                crate::spec_parser::ValidationError::new(
380                    "Cannot perform enhanced validation without raw document".to_string(),
381                ),
382            ])
383        }
384    }
385
386    /// Get the OpenAPI version
387    pub fn version(&self) -> &str {
388        &self.spec.openapi
389    }
390
391    /// Get the API title
392    pub fn title(&self) -> &str {
393        &self.spec.info.title
394    }
395
396    /// Get the API description
397    pub fn description(&self) -> Option<&str> {
398        self.spec.info.description.as_deref()
399    }
400
401    /// Get the API version
402    pub fn api_version(&self) -> &str {
403        &self.spec.info.version
404    }
405
406    /// Get the server URLs
407    pub fn servers(&self) -> &[openapiv3::Server] {
408        &self.spec.servers
409    }
410
411    /// Get all paths defined in the spec
412    pub fn paths(&self) -> &openapiv3::Paths {
413        &self.spec.paths
414    }
415
416    /// Get all schemas defined in the spec
417    pub fn schemas(&self) -> Option<&indexmap::IndexMap<String, ReferenceOr<Schema>>> {
418        self.spec.components.as_ref().map(|c| &c.schemas)
419    }
420
421    /// Get all security schemes defined in the spec
422    pub fn security_schemes(
423        &self,
424    ) -> Option<&indexmap::IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>>> {
425        self.spec.components.as_ref().map(|c| &c.security_schemes)
426    }
427
428    /// Get all operations for a given path
429    pub fn operations_for_path(
430        &self,
431        path: &str,
432    ) -> std::collections::HashMap<String, openapiv3::Operation> {
433        let mut operations = std::collections::HashMap::new();
434
435        if let Some(path_item_ref) = self.spec.paths.paths.get(path) {
436            // Handle the ReferenceOr<PathItem> case
437            if let Some(path_item) = path_item_ref.as_item() {
438                // Round 40 (#888 / #79) — Srikanth's Google Apigee
439                // spec puts the shared auth / format query
440                // parameters at PATH level, not on each operation.
441                // OpenAPI 3.0 §4.7.10.1: "Parameters that are
442                // included in the Operation Object inherit the
443                // parameters defined in the Path Item Object. If a
444                // parameter is already defined at the Path Item, the
445                // new definition will override it but can never
446                // remove it." We materialise that inheritance HERE
447                // (the lowest common point under both registry
448                // builders), so a request that violates a path-level
449                // `enum` or `type: boolean` reaches the validator's
450                // parameter loop instead of silently passing. We
451                // also resolve `$ref` parameters via
452                // `components.parameters` so the validator's loop
453                // (which skips `ReferenceOr::Reference` entries via
454                // `as_item()`) actually sees them.
455                let resolved_path_params: Vec<ReferenceOr<openapiv3::Parameter>> =
456                    path_item.parameters.iter().map(|p| self.resolve_parameter_ref(p)).collect();
457                let merge = |op: &openapiv3::Operation| -> openapiv3::Operation {
458                    // Resolve op-level refs too — same as path-level.
459                    let mut resolved_op = op.clone();
460                    resolved_op.parameters =
461                        op.parameters.iter().map(|p| self.resolve_parameter_ref(p)).collect();
462                    merge_path_params_into_operation(&resolved_op, &resolved_path_params)
463                };
464                if let Some(op) = &path_item.get {
465                    operations.insert("GET".to_string(), merge(op));
466                }
467                if let Some(op) = &path_item.post {
468                    operations.insert("POST".to_string(), merge(op));
469                }
470                if let Some(op) = &path_item.put {
471                    operations.insert("PUT".to_string(), merge(op));
472                }
473                if let Some(op) = &path_item.delete {
474                    operations.insert("DELETE".to_string(), merge(op));
475                }
476                if let Some(op) = &path_item.patch {
477                    operations.insert("PATCH".to_string(), merge(op));
478                }
479                if let Some(op) = &path_item.head {
480                    operations.insert("HEAD".to_string(), merge(op));
481                }
482                if let Some(op) = &path_item.options {
483                    operations.insert("OPTIONS".to_string(), merge(op));
484                }
485                if let Some(op) = &path_item.trace {
486                    operations.insert("TRACE".to_string(), merge(op));
487                }
488            }
489        }
490
491        operations
492    }
493
494    /// Get all paths with their operations
495    pub fn all_paths_and_operations(
496        &self,
497    ) -> std::collections::HashMap<String, std::collections::HashMap<String, openapiv3::Operation>>
498    {
499        self.spec
500            .paths
501            .paths
502            .iter()
503            .map(|(path, _)| (path.clone(), self.operations_for_path(path)))
504            .collect()
505    }
506
507    /// Get a schema by reference (returns wrapped OpenApiSchema)
508    pub fn get_schema(&self, reference: &str) -> Option<crate::schema::OpenApiSchema> {
509        self.resolve_schema(reference).map(crate::schema::OpenApiSchema::new)
510    }
511
512    /// Resolve a schema reference to the raw Schema
513    ///
514    /// This resolves `$ref` references like `#/components/schemas/User` to the
515    /// actual schema definition, handling nested references recursively.
516    pub fn resolve_schema_ref(&self, reference: &str) -> Option<Schema> {
517        self.resolve_schema(reference)
518    }
519
520    /// Round 40 (#888 / #79) — resolve a parameter `$ref` (typically
521    /// `#/components/parameters/foo`) into the inline `Parameter`
522    /// item it points at. Returns the input unchanged when the
523    /// reference can't be resolved (e.g. external `$ref`) so the
524    /// validator can fall back to its prior behaviour (skip via
525    /// `as_item()`) instead of panicking. Used by
526    /// `operations_for_path` to materialise refs at registry build
527    /// time, since the validator's parameter loop skips
528    /// `ReferenceOr::Reference` entries — which was why Srikanth's
529    /// Google Apigee spec silently passed every path-level param
530    /// violation: the path-level `parameters:` list is entirely
531    /// `$ref:` to shared common params like `_.xgafv`,
532    /// `prettyPrint`, etc.
533    pub fn resolve_parameter_ref(
534        &self,
535        p_ref: &ReferenceOr<openapiv3::Parameter>,
536    ) -> ReferenceOr<openapiv3::Parameter> {
537        match p_ref {
538            ReferenceOr::Item(_) => p_ref.clone(),
539            ReferenceOr::Reference { reference } => {
540                let Some(name) = reference.strip_prefix("#/components/parameters/") else {
541                    return p_ref.clone();
542                };
543                let Some(components) = self.spec.components.as_ref() else {
544                    return p_ref.clone();
545                };
546                match components.parameters.get(name) {
547                    Some(ReferenceOr::Item(p)) => ReferenceOr::Item(p.clone()),
548                    Some(ReferenceOr::Reference { reference: nested }) => {
549                        // Tail-resolve a chained ref (rare in practice
550                        // but allowed by the spec).
551                        let Some(nested_name) = nested.strip_prefix("#/components/parameters/")
552                        else {
553                            return p_ref.clone();
554                        };
555                        match components.parameters.get(nested_name) {
556                            Some(ReferenceOr::Item(p)) => ReferenceOr::Item(p.clone()),
557                            _ => p_ref.clone(),
558                        }
559                    }
560                    None => p_ref.clone(),
561                }
562            }
563        }
564    }
565
566    /// Validate security requirements
567    pub fn validate_security_requirements(
568        &self,
569        security_requirements: &[openapiv3::SecurityRequirement],
570        auth_header: Option<&str>,
571        api_key: Option<&str>,
572    ) -> Result<()> {
573        if security_requirements.is_empty() {
574            return Ok(());
575        }
576
577        // Security requirements are OR'd - if any requirement is satisfied, pass
578        for requirement in security_requirements {
579            if self.is_security_requirement_satisfied(requirement, auth_header, api_key)? {
580                return Ok(());
581            }
582        }
583
584        Err(Error::validation(
585            "Security validation failed: no valid authentication provided",
586        ))
587    }
588
589    fn resolve_schema(&self, reference: &str) -> Option<Schema> {
590        let mut visited = HashSet::new();
591        self.resolve_schema_recursive(reference, &mut visited)
592    }
593
594    fn resolve_schema_recursive(
595        &self,
596        reference: &str,
597        visited: &mut HashSet<String>,
598    ) -> Option<Schema> {
599        if !visited.insert(reference.to_string()) {
600            tracing::warn!("Detected recursive schema reference: {}", reference);
601            return None;
602        }
603
604        let schema_name = reference.strip_prefix("#/components/schemas/")?;
605        let components = self.spec.components.as_ref()?;
606        let schema_ref = components.schemas.get(schema_name)?;
607
608        match schema_ref {
609            ReferenceOr::Item(schema) => Some(schema.clone()),
610            ReferenceOr::Reference { reference: nested } => {
611                self.resolve_schema_recursive(nested, visited)
612            }
613        }
614    }
615
616    /// Check if a single security requirement is satisfied
617    fn is_security_requirement_satisfied(
618        &self,
619        requirement: &openapiv3::SecurityRequirement,
620        auth_header: Option<&str>,
621        api_key: Option<&str>,
622    ) -> Result<bool> {
623        // All schemes in the requirement must be satisfied (AND)
624        for (scheme_name, _scopes) in requirement {
625            if !self.is_security_scheme_satisfied(scheme_name, auth_header, api_key)? {
626                return Ok(false);
627            }
628        }
629        Ok(true)
630    }
631
632    /// Check if a security scheme is satisfied
633    fn is_security_scheme_satisfied(
634        &self,
635        scheme_name: &str,
636        auth_header: Option<&str>,
637        api_key: Option<&str>,
638    ) -> Result<bool> {
639        let security_schemes = match self.security_schemes() {
640            Some(schemes) => schemes,
641            None => return Ok(false),
642        };
643
644        let scheme = match security_schemes.get(scheme_name) {
645            Some(scheme) => scheme,
646            None => {
647                return Err(Error::config(format!("Security scheme '{}' not found", scheme_name)))
648            }
649        };
650
651        let scheme = match scheme {
652            ReferenceOr::Item(s) => s,
653            ReferenceOr::Reference { reference } => {
654                // Resolve $ref like "#/components/securitySchemes/BearerAuth"
655                let ref_name =
656                    reference.strip_prefix("#/components/securitySchemes/").ok_or_else(|| {
657                        Error::config(format!(
658                            "Unsupported security scheme reference format: {}",
659                            reference
660                        ))
661                    })?;
662                match security_schemes.get(ref_name) {
663                    Some(ReferenceOr::Item(resolved)) => resolved,
664                    Some(ReferenceOr::Reference { .. }) => {
665                        return Err(Error::config(format!(
666                            "Nested security scheme reference not supported: {}",
667                            ref_name
668                        )))
669                    }
670                    None => {
671                        return Err(Error::config(format!(
672                            "Security scheme '{}' not found",
673                            ref_name
674                        )))
675                    }
676                }
677            }
678        };
679
680        match scheme {
681            openapiv3::SecurityScheme::HTTP { scheme, .. } => {
682                match scheme.as_str() {
683                    "bearer" => match auth_header {
684                        Some(header) if header.starts_with("Bearer ") => Ok(true),
685                        _ => Ok(false),
686                    },
687                    "basic" => match auth_header {
688                        Some(header) if header.starts_with("Basic ") => Ok(true),
689                        _ => Ok(false),
690                    },
691                    _ => Ok(false), // Unsupported scheme
692                }
693            }
694            openapiv3::SecurityScheme::APIKey { location, .. } => match location {
695                openapiv3::APIKeyLocation::Header => Ok(auth_header.is_some()),
696                openapiv3::APIKeyLocation::Query => Ok(api_key.is_some()),
697                openapiv3::APIKeyLocation::Cookie => Ok(api_key.is_some()),
698            },
699            openapiv3::SecurityScheme::OpenIDConnect { .. } => {
700                // OpenID Connect uses Bearer tokens, same as OAuth2
701                match auth_header {
702                    Some(header) if header.starts_with("Bearer ") => Ok(true),
703                    _ => Ok(false),
704                }
705            }
706            openapiv3::SecurityScheme::OAuth2 { .. } => {
707                // For OAuth2, check if Bearer token is provided
708                match auth_header {
709                    Some(header) if header.starts_with("Bearer ") => Ok(true),
710                    _ => Ok(false),
711                }
712            }
713        }
714    }
715
716    /// Get global security requirements
717    pub fn get_global_security_requirements(&self) -> Vec<openapiv3::SecurityRequirement> {
718        self.spec.security.clone().unwrap_or_default()
719    }
720
721    /// Resolve a request body reference
722    pub fn get_request_body(&self, reference: &str) -> Option<&openapiv3::RequestBody> {
723        if let Some(components) = &self.spec.components {
724            if let Some(param_name) = reference.strip_prefix("#/components/requestBodies/") {
725                if let Some(request_body_ref) = components.request_bodies.get(param_name) {
726                    return request_body_ref.as_item();
727                }
728            }
729        }
730        None
731    }
732
733    /// Resolve a response reference
734    pub fn get_response(&self, reference: &str) -> Option<&openapiv3::Response> {
735        if let Some(components) = &self.spec.components {
736            if let Some(response_name) = reference.strip_prefix("#/components/responses/") {
737                if let Some(response_ref) = components.responses.get(response_name) {
738                    return response_ref.as_item();
739                }
740            }
741        }
742        None
743    }
744
745    /// Resolve an example reference
746    pub fn get_example(&self, reference: &str) -> Option<&openapiv3::Example> {
747        if let Some(components) = &self.spec.components {
748            if let Some(example_name) = reference.strip_prefix("#/components/examples/") {
749                if let Some(example_ref) = components.examples.get(example_name) {
750                    return example_ref.as_item();
751                }
752            }
753        }
754        None
755    }
756}
757
758/// Round 40 (#888 / #79) — merge path-level parameters from a
759/// `PathItem` into an `Operation`'s own parameters per OpenAPI 3.0
760/// §4.7.10.1. Returns a cloned `Operation` whose `parameters` list
761/// contains every path-level entry, followed by every operation-level
762/// entry, with collisions on `(name, in)` resolved in favour of the
763/// operation-level definition. The original `Operation` is not
764/// mutated. Lives in `spec.rs` so every registry builder that calls
765/// `operations_for_path` benefits from the merge automatically.
766pub(crate) fn merge_path_params_into_operation(
767    operation: &openapiv3::Operation,
768    path_level_params: &[ReferenceOr<openapiv3::Parameter>],
769) -> openapiv3::Operation {
770    use std::collections::HashSet;
771    if path_level_params.is_empty() {
772        return operation.clone();
773    }
774    let mut op_keys: HashSet<(String, String)> = HashSet::new();
775    for p_ref in &operation.parameters {
776        if let Some(key) = parameter_key(p_ref) {
777            op_keys.insert(key);
778        }
779    }
780    let mut merged: Vec<ReferenceOr<openapiv3::Parameter>> =
781        Vec::with_capacity(path_level_params.len() + operation.parameters.len());
782    for p_ref in path_level_params {
783        match parameter_key(p_ref) {
784            Some(key) if op_keys.contains(&key) => {}
785            _ => merged.push(p_ref.clone()),
786        }
787    }
788    merged.extend(operation.parameters.iter().cloned());
789    let mut cloned = operation.clone();
790    cloned.parameters = merged;
791    cloned
792}
793
794fn parameter_key(p_ref: &ReferenceOr<openapiv3::Parameter>) -> Option<(String, String)> {
795    let p = p_ref.as_item()?;
796    let (name, in_loc) = match p {
797        openapiv3::Parameter::Path { parameter_data, .. } => (parameter_data.name.clone(), "path"),
798        openapiv3::Parameter::Query { parameter_data, .. } => {
799            (parameter_data.name.clone(), "query")
800        }
801        openapiv3::Parameter::Header { parameter_data, .. } => {
802            (parameter_data.name.clone(), "header")
803        }
804        openapiv3::Parameter::Cookie { parameter_data, .. } => {
805            (parameter_data.name.clone(), "cookie")
806        }
807    };
808    Some((name, in_loc.to_string()))
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use openapiv3::{SchemaKind, Type};
815
816    #[test]
817    fn resolves_security_scheme_ref() {
818        let yaml = r#"
819openapi: 3.0.3
820info:
821  title: Test API
822  version: "1.0.0"
823paths:
824  /test:
825    get:
826      security:
827        - BearerRef: []
828      responses:
829        '200':
830          description: OK
831components:
832  securitySchemes:
833    BearerAuth:
834      type: http
835      scheme: bearer
836    BearerRef:
837      $ref: '#/components/securitySchemes/BearerAuth'
838        "#;
839
840        let spec = OpenApiSpec::from_string(yaml, Some("yaml")).expect("spec parses");
841
842        // Bearer token should satisfy the referenced scheme
843        let result = spec
844            .is_security_scheme_satisfied("BearerRef", Some("Bearer token123"), None)
845            .expect("should resolve ref");
846        assert!(result);
847
848        // Missing token should fail
849        let result = spec
850            .is_security_scheme_satisfied("BearerRef", None, None)
851            .expect("should resolve ref");
852        assert!(!result);
853    }
854
855    #[test]
856    fn resolves_nested_schema_references() {
857        let yaml = r#"
858openapi: 3.0.3
859info:
860  title: Test API
861  version: "1.0.0"
862paths: {}
863components:
864  schemas:
865    Apiary:
866      type: object
867      properties:
868        id:
869          type: string
870        hive:
871          $ref: '#/components/schemas/Hive'
872    Hive:
873      type: object
874      properties:
875        name:
876          type: string
877    HiveWrapper:
878      $ref: '#/components/schemas/Hive'
879        "#;
880
881        let spec = OpenApiSpec::from_string(yaml, Some("yaml")).expect("spec parses");
882
883        let apiary = spec.get_schema("#/components/schemas/Apiary").expect("resolve apiary schema");
884        assert!(matches!(apiary.schema.schema_kind, SchemaKind::Type(Type::Object(_))));
885
886        let wrapper = spec
887            .get_schema("#/components/schemas/HiveWrapper")
888            .expect("resolve wrapper schema");
889        assert!(matches!(wrapper.schema.schema_kind, SchemaKind::Type(Type::Object(_))));
890    }
891}
892
893#[cfg(test)]
894mod missing_responses_tests {
895    use super::*;
896
897    /// A proxy-generated spec with no `responses` on its operations must load.
898    /// Reported on #79: 63 of 70 operations lacked the field and the whole run
899    /// aborted with `missing field \`responses\``.
900    #[test]
901    fn spec_without_operation_responses_loads() {
902        let raw = r#"{
903            "openapi": "3.0.0",
904            "info": { "title": "proxy-generated", "version": "1.0.0" },
905            "paths": {
906                "/orders": {
907                    "get": { "summary": "list" },
908                    "post": {
909                        "summary": "create",
910                        "requestBody": {
911                            "content": { "application/json": { "schema": { "type": "object" } } }
912                        }
913                    }
914                }
915            }
916        }"#;
917
918        let spec = OpenApiSpec::from_string(raw, Some("json"))
919            .expect("a spec missing `responses` should still load for bench/load use");
920
921        let item = spec.spec.paths.paths.get("/orders").expect("path present");
922        let ReferenceOr::Item(item) = item else {
923            panic!("expected inline path item");
924        };
925        assert!(item.get.is_some(), "GET survived the repair");
926        assert!(item.post.is_some(), "POST survived the repair");
927        assert!(
928            item.post.as_ref().unwrap().request_body.is_some(),
929            "requestBody must be preserved — it is what request generation actually reads"
930        );
931    }
932
933    /// The repair must only add the mandatory field, never touch a spec that
934    /// already declares responses.
935    #[test]
936    fn existing_responses_are_left_alone() {
937        let mut raw: serde_json::Value = serde_json::from_str(
938            r#"{"paths": {"/a": {"get": {"responses": {"200": {"description": "ok"}}}}}}"#,
939        )
940        .unwrap();
941        let before = raw.clone();
942        assert_eq!(fill_missing_operation_responses(&mut raw), 0);
943        assert_eq!(raw, before, "a conformant spec must be byte-identical after the pass");
944    }
945
946    /// Non-operation keys under a path item (parameters, servers, $ref) must not
947    /// be mistaken for operations.
948    #[test]
949    fn non_operation_keys_are_not_touched() {
950        let mut raw: serde_json::Value = serde_json::from_str(
951            r#"{"paths": {"/a": {"parameters": [], "servers": [], "get": {}}}}"#,
952        )
953        .unwrap();
954        assert_eq!(fill_missing_operation_responses(&mut raw), 1, "only `get` is an operation");
955        let path = &raw["paths"]["/a"];
956        assert!(path["parameters"].is_array(), "parameters untouched");
957        assert!(path["servers"].is_array(), "servers untouched");
958        assert!(path["get"]["responses"].is_object(), "get repaired");
959    }
960}
961
962#[cfg(test)]
963mod oas31_type_array_tests {
964    use super::*;
965    use openapiv3::{SchemaKind, Type};
966
967    /// Srikanth #79 (i): OAS 3.1 `type: ["string", "null"]` must load, because
968    /// that is what LLM-generated specs emit and `openapiv3` cannot deserialize
969    /// a sequence into a string `type`.
970    #[test]
971    fn oas31_nullable_type_array_loads() {
972        let raw = r#"{
973            "openapi": "3.1.0",
974            "info": { "title": "limit", "version": "1" },
975            "paths": {
976                "/companies": {
977                    "post": {
978                        "responses": { "200": { "description": "ok" } },
979                        "requestBody": {
980                            "content": {
981                                "application/json": {
982                                    "schema": {
983                                        "type": "object",
984                                        "properties": {
985                                            "taxId": { "type": ["string", "null"] }
986                                        },
987                                        "required": ["taxId"]
988                                    }
989                                }
990                            }
991                        }
992                    }
993                }
994            },
995            "components": {
996                "schemas": {
997                    "ResourceBase": {
998                        "type": "object",
999                        "properties": {
1000                            "nickname": { "type": ["null", "string"] }
1001                        }
1002                    }
1003                }
1004            }
1005        }"#;
1006
1007        let spec = OpenApiSpec::from_string(raw, Some("json"))
1008            .expect("OAS 3.1 type: [string, null] must load for bench use");
1009
1010        let schema = spec
1011            .resolve_schema_ref("#/components/schemas/ResourceBase")
1012            .expect("ResourceBase present");
1013        let SchemaKind::Type(Type::Object(obj)) = schema.schema_kind else {
1014            panic!("ResourceBase should be an object after the type-array coerce");
1015        };
1016        let nickname = obj.properties.get("nickname").expect("nickname property");
1017        let ReferenceOr::Item(nickname) = nickname else {
1018            panic!("nickname should be inline");
1019        };
1020        assert!(
1021            nickname.schema_data.nullable,
1022            "`null` in the 3.1 type union becomes OAS 3.0 nullable"
1023        );
1024        assert!(
1025            matches!(nickname.schema_kind, SchemaKind::Type(Type::String(_))),
1026            "remaining primitive is string"
1027        );
1028    }
1029
1030    /// `required` is a string array too. The walker must not treat it as `type`.
1031    #[test]
1032    fn required_arrays_are_not_rewritten() {
1033        let mut raw: serde_json::Value = serde_json::from_str(
1034            r#"{
1035                "components": {
1036                    "schemas": {
1037                        "A": { "type": "object", "required": ["a", "b"] }
1038                    }
1039                }
1040            }"#,
1041        )
1042        .unwrap();
1043        let before = raw.clone();
1044        assert_eq!(coerce_json_schema_type_arrays(&mut raw), 0);
1045        assert_eq!(raw, before, "required arrays must stay arrays of strings");
1046    }
1047
1048    /// A lone `["string"]` (legal OAS 3.1) becomes a 3.0 string type, with no
1049    /// `nullable` flag invented.
1050    #[test]
1051    fn singleton_type_array_becomes_string() {
1052        let mut raw: serde_json::Value =
1053            serde_json::from_str(r#"{"properties": {"id": {"type": ["string"]}}}"#).unwrap();
1054        assert_eq!(coerce_json_schema_type_arrays(&mut raw), 1);
1055        assert_eq!(raw["properties"]["id"]["type"], "string");
1056        assert!(raw["properties"]["id"].get("nullable").is_none());
1057    }
1058}