Skip to main content

oa_forge_parser/
lib.rs

1pub mod openapi;
2pub mod resolver;
3
4pub use openapi::OpenApiSpec;
5
6use std::collections::HashSet;
7use std::path::Path;
8
9use thiserror::Error;
10
11#[derive(Error, Debug)]
12pub enum ParseError {
13    #[error("failed to parse YAML: {0}")]
14    Yaml(#[from] serde_yaml::Error),
15
16    #[error("failed to parse JSON: {0}")]
17    Json(#[from] serde_json::Error),
18
19    #[error("unsupported OpenAPI version: {0}")]
20    UnsupportedVersion(String),
21
22    #[error("unresolved $ref: {0}")]
23    UnresolvedRef(String),
24
25    #[error("failed to read file {path}: {source}")]
26    FileRead {
27        path: String,
28        source: std::io::Error,
29    },
30}
31
32mod swagger2;
33
34/// Parse an OpenAPI spec from a YAML or JSON string.
35/// Swagger 2.0 specs are automatically converted to OpenAPI 3.0.
36pub fn parse(input: &str) -> Result<OpenApiSpec, ParseError> {
37    // Try YAML first (YAML is a superset of JSON)
38    let raw: serde_yaml::Value = serde_yaml::from_str(input)?;
39
40    // Detect Swagger 2.0 and convert
41    if let Some(swagger_ver) = raw.get("swagger").and_then(|v| v.as_str()) {
42        if swagger_ver.starts_with("2.") {
43            let converted = swagger2::convert_to_openapi3(raw)?;
44            let yaml_str = serde_yaml::to_string(&converted)?;
45            let spec: OpenApiSpec = serde_yaml::from_str(&yaml_str)?;
46            return Ok(spec);
47        }
48    }
49
50    let spec: OpenApiSpec = serde_yaml::from_str(input)?;
51
52    match spec.openapi.as_str() {
53        v if v.starts_with("3.0") || v.starts_with("3.1") => Ok(spec),
54        v => Err(ParseError::UnsupportedVersion(v.to_string())),
55    }
56}
57
58/// Parse an OpenAPI spec from a file path, resolving cross-file `$ref` references.
59/// External `$ref` values like `./models.yaml#/components/schemas/Pet` are loaded
60/// and their schemas are merged into the main spec's `components.schemas`.
61pub fn parse_file(path: &Path) -> Result<OpenApiSpec, ParseError> {
62    let content = std::fs::read_to_string(path).map_err(|e| ParseError::FileRead {
63        path: path.display().to_string(),
64        source: e,
65    })?;
66    let mut spec = parse(&content)?;
67
68    let base_dir = path.parent().unwrap_or(Path::new("."));
69    resolve_external_refs(&mut spec, base_dir, &mut HashSet::new())?;
70
71    Ok(spec)
72}
73
74/// Walk the spec's raw YAML to find cross-file $ref strings, load external files,
75/// and merge their schemas into the spec's components.
76fn resolve_external_refs(
77    spec: &mut OpenApiSpec,
78    base_dir: &Path,
79    visited_files: &mut HashSet<String>,
80) -> Result<(), ParseError> {
81    // Collect all $ref strings that point to external files
82    let external_refs = collect_external_refs(spec);
83    if external_refs.is_empty() {
84        return Ok(());
85    }
86
87    for ext_ref in external_refs {
88        // Split "path/to/file.yaml#/components/schemas/Name" into (file_path, json_pointer)
89        let (file_part, pointer_part) = match ext_ref.split_once('#') {
90            Some((f, p)) => (f.to_string(), p.to_string()),
91            None => continue,
92        };
93
94        if file_part.is_empty() {
95            continue; // Local ref, skip
96        }
97
98        // Reject URL-scheme refs (potential SSRF surface if networking is ever added)
99        if file_part.contains("://") {
100            continue;
101        }
102
103        let resolved_path = base_dir.join(&file_part);
104
105        // Canonicalize to prevent path traversal via `../` and detect symlink loops
106        let canonical =
107            std::fs::canonicalize(&resolved_path).map_err(|e| ParseError::FileRead {
108                path: resolved_path.display().to_string(),
109                source: e,
110            })?;
111        let canonical_str = canonical.display().to_string();
112
113        if visited_files.contains(&canonical_str) {
114            continue; // Already processed
115        }
116        visited_files.insert(canonical_str);
117
118        let ext_content =
119            std::fs::read_to_string(&canonical).map_err(|e| ParseError::FileRead {
120                path: canonical.display().to_string(),
121                source: e,
122            })?;
123
124        // Parse the external file as a partial spec (may have components)
125        let mut ext_spec: OpenApiSpec = serde_yaml::from_str(&ext_content)?;
126
127        // Recursively resolve external $refs in the loaded file,
128        // using its own directory as base (double-indirect resolution).
129        let ext_base_dir = resolved_path.parent().unwrap_or(Path::new("."));
130        resolve_external_refs(&mut ext_spec, ext_base_dir, visited_files)?;
131
132        // Merge external schemas into main spec's components
133        if let Some(ext_components) = &ext_spec.components {
134            let components = spec.components.get_or_insert_with(|| openapi::Components {
135                schemas: Default::default(),
136                parameters: Default::default(),
137                request_bodies: Default::default(),
138                responses: Default::default(),
139            });
140
141            for (name, schema) in &ext_components.schemas {
142                if !components.schemas.contains_key(name) {
143                    components.schemas.insert(name.clone(), schema.clone());
144                }
145            }
146
147            for (name, param) in &ext_components.parameters {
148                if !components.parameters.contains_key(name) {
149                    components.parameters.insert(name.clone(), param.clone());
150                }
151            }
152        }
153
154        // Rewrite external $refs to local $refs in the main spec
155        rewrite_refs(spec, &file_part, &pointer_part);
156    }
157
158    // Re-check: merged schemas may contain external refs from deeper files
159    // that are now relative to the main spec's base_dir
160    let remaining = collect_external_refs(spec);
161    if !remaining.is_empty() {
162        resolve_external_refs(spec, base_dir, visited_files)?;
163    }
164
165    Ok(())
166}
167
168/// Collect all $ref strings that reference external files (contain a file path before #).
169fn collect_external_refs(spec: &OpenApiSpec) -> Vec<String> {
170    let mut refs = Vec::new();
171
172    if let Some(components) = &spec.components {
173        collect_refs_from_schemas(&components.schemas, &mut refs);
174    }
175
176    for (_path, item) in &spec.paths {
177        for (_method, op) in item.operations() {
178            for param in &op.parameters {
179                if let openapi::ParameterOrRef::Ref { ref_path } = param
180                    && ref_path.contains('/')
181                    && !ref_path.starts_with('#')
182                {
183                    refs.push(ref_path.clone());
184                }
185            }
186            if let Some(openapi::RequestBodyOrRef::Ref { ref_path }) = &op.request_body
187                && !ref_path.starts_with('#')
188            {
189                refs.push(ref_path.clone());
190            }
191            for (_code, resp) in &op.responses {
192                if let openapi::ResponseOrRef::Ref { ref_path } = resp
193                    && !ref_path.starts_with('#')
194                {
195                    refs.push(ref_path.clone());
196                }
197            }
198        }
199    }
200
201    refs.sort();
202    refs.dedup();
203    refs
204}
205
206fn collect_refs_from_schemas(
207    schemas: &indexmap::IndexMap<String, openapi::SchemaOrRef>,
208    refs: &mut Vec<String>,
209) {
210    for schema_or_ref in schemas.values() {
211        collect_refs_from_schema_or_ref(schema_or_ref, refs);
212    }
213}
214
215fn collect_refs_from_schema_or_ref(schema_or_ref: &openapi::SchemaOrRef, refs: &mut Vec<String>) {
216    match schema_or_ref {
217        openapi::SchemaOrRef::Ref { ref_path } => {
218            if !ref_path.starts_with('#') {
219                refs.push(ref_path.clone());
220            }
221        }
222        openapi::SchemaOrRef::Schema(schema) => {
223            for prop in schema.properties.values() {
224                collect_refs_from_schema_or_ref(prop, refs);
225            }
226            if let Some(items) = &schema.items {
227                collect_refs_from_schema_or_ref(items, refs);
228            }
229            if let Some(all_of) = &schema.all_of {
230                for s in all_of {
231                    collect_refs_from_schema_or_ref(s, refs);
232                }
233            }
234            if let Some(one_of) = &schema.one_of {
235                for s in one_of {
236                    collect_refs_from_schema_or_ref(s, refs);
237                }
238            }
239            if let Some(any_of) = &schema.any_of {
240                for s in any_of {
241                    collect_refs_from_schema_or_ref(s, refs);
242                }
243            }
244            if let Some(openapi::AdditionalProperties::Schema(ap)) = &schema.additional_properties {
245                collect_refs_from_schema_or_ref(ap, refs);
246            }
247        }
248    }
249}
250
251/// Rewrite external $ref paths to local #/components/schemas/ paths.
252fn rewrite_refs(spec: &mut OpenApiSpec, file_part: &str, _pointer_part: &str) {
253    if let Some(components) = &mut spec.components {
254        rewrite_refs_in_schemas(&mut components.schemas, file_part);
255    }
256
257    for (_path, item) in &mut spec.paths {
258        for (_method, op) in item.operations_mut() {
259            for param in &mut op.parameters {
260                if let openapi::ParameterOrRef::Ref { ref_path } = param {
261                    rewrite_single_ref(ref_path, file_part);
262                }
263            }
264            if let Some(openapi::RequestBodyOrRef::Ref { ref_path }) = &mut op.request_body {
265                rewrite_single_ref(ref_path, file_part);
266            }
267            for (_code, resp) in &mut op.responses {
268                if let openapi::ResponseOrRef::Ref { ref_path } = resp {
269                    rewrite_single_ref(ref_path, file_part);
270                }
271            }
272        }
273    }
274}
275
276fn rewrite_refs_in_schemas(
277    schemas: &mut indexmap::IndexMap<String, openapi::SchemaOrRef>,
278    file_part: &str,
279) {
280    for schema_or_ref in schemas.values_mut() {
281        rewrite_refs_in_schema_or_ref(schema_or_ref, file_part);
282    }
283}
284
285fn rewrite_refs_in_schema_or_ref(schema_or_ref: &mut openapi::SchemaOrRef, file_part: &str) {
286    match schema_or_ref {
287        openapi::SchemaOrRef::Ref { ref_path } => {
288            rewrite_single_ref(ref_path, file_part);
289        }
290        openapi::SchemaOrRef::Schema(schema) => {
291            for prop in schema.properties.values_mut() {
292                rewrite_refs_in_schema_or_ref(prop, file_part);
293            }
294            if let Some(items) = &mut schema.items {
295                rewrite_refs_in_schema_or_ref(items, file_part);
296            }
297            if let Some(all_of) = &mut schema.all_of {
298                for s in all_of {
299                    rewrite_refs_in_schema_or_ref(s, file_part);
300                }
301            }
302            if let Some(one_of) = &mut schema.one_of {
303                for s in one_of {
304                    rewrite_refs_in_schema_or_ref(s, file_part);
305                }
306            }
307            if let Some(any_of) = &mut schema.any_of {
308                for s in any_of {
309                    rewrite_refs_in_schema_or_ref(s, file_part);
310                }
311            }
312            if let Some(openapi::AdditionalProperties::Schema(ap)) =
313                &mut schema.additional_properties
314            {
315                rewrite_refs_in_schema_or_ref(ap, file_part);
316            }
317        }
318    }
319}
320
321/// Rewrite a single external $ref like "./models.yaml#/components/schemas/Pet"
322/// to a local $ref "#/components/schemas/Pet".
323fn rewrite_single_ref(ref_path: &mut String, file_part: &str) {
324    if ref_path.starts_with(file_part)
325        && let Some(hash_pos) = ref_path.find('#')
326    {
327        *ref_path = ref_path[hash_pos..].to_string();
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn parse_petstore_version() {
337        let yaml = r#"
338openapi: "3.0.3"
339info:
340  title: Petstore
341  version: "1.0.0"
342paths: {}
343"#;
344        let spec = parse(yaml).unwrap();
345        assert_eq!(spec.openapi, "3.0.3");
346        assert_eq!(spec.info.title, "Petstore");
347    }
348
349    #[test]
350    fn reject_unsupported_version() {
351        let yaml = r#"
352openapi: "2.0"
353info:
354  title: Old
355  version: "1.0.0"
356paths: {}
357"#;
358        assert!(parse(yaml).is_err());
359    }
360}