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        let resolved_path = base_dir.join(&file_part);
99        let canonical = resolved_path.display().to_string();
100
101        if visited_files.contains(&canonical) {
102            continue; // Already processed
103        }
104        visited_files.insert(canonical.clone());
105
106        let ext_content =
107            std::fs::read_to_string(&resolved_path).map_err(|e| ParseError::FileRead {
108                path: resolved_path.display().to_string(),
109                source: e,
110            })?;
111
112        // Parse the external file as a partial spec (may have components)
113        let mut ext_spec: OpenApiSpec = serde_yaml::from_str(&ext_content)?;
114
115        // Recursively resolve external $refs in the loaded file,
116        // using its own directory as base (double-indirect resolution).
117        let ext_base_dir = resolved_path.parent().unwrap_or(Path::new("."));
118        resolve_external_refs(&mut ext_spec, ext_base_dir, visited_files)?;
119
120        // Merge external schemas into main spec's components
121        if let Some(ext_components) = &ext_spec.components {
122            let components = spec.components.get_or_insert_with(|| openapi::Components {
123                schemas: Default::default(),
124                parameters: Default::default(),
125                request_bodies: Default::default(),
126                responses: Default::default(),
127            });
128
129            for (name, schema) in &ext_components.schemas {
130                if !components.schemas.contains_key(name) {
131                    components.schemas.insert(name.clone(), schema.clone());
132                }
133            }
134
135            for (name, param) in &ext_components.parameters {
136                if !components.parameters.contains_key(name) {
137                    components.parameters.insert(name.clone(), param.clone());
138                }
139            }
140        }
141
142        // Rewrite external $refs to local $refs in the main spec
143        rewrite_refs(spec, &file_part, &pointer_part);
144    }
145
146    // Re-check: merged schemas may contain external refs from deeper files
147    // that are now relative to the main spec's base_dir
148    let remaining = collect_external_refs(spec);
149    if !remaining.is_empty() {
150        resolve_external_refs(spec, base_dir, visited_files)?;
151    }
152
153    Ok(())
154}
155
156/// Collect all $ref strings that reference external files (contain a file path before #).
157fn collect_external_refs(spec: &OpenApiSpec) -> Vec<String> {
158    let mut refs = Vec::new();
159
160    if let Some(components) = &spec.components {
161        collect_refs_from_schemas(&components.schemas, &mut refs);
162    }
163
164    for (_path, item) in &spec.paths {
165        for (_method, op) in item.operations() {
166            for param in &op.parameters {
167                if let openapi::ParameterOrRef::Ref { ref_path } = param
168                    && ref_path.contains('/')
169                    && !ref_path.starts_with('#')
170                {
171                    refs.push(ref_path.clone());
172                }
173            }
174            if let Some(openapi::RequestBodyOrRef::Ref { ref_path }) = &op.request_body
175                && !ref_path.starts_with('#')
176            {
177                refs.push(ref_path.clone());
178            }
179            for (_code, resp) in &op.responses {
180                if let openapi::ResponseOrRef::Ref { ref_path } = resp
181                    && !ref_path.starts_with('#')
182                {
183                    refs.push(ref_path.clone());
184                }
185            }
186        }
187    }
188
189    refs.sort();
190    refs.dedup();
191    refs
192}
193
194fn collect_refs_from_schemas(
195    schemas: &indexmap::IndexMap<String, openapi::SchemaOrRef>,
196    refs: &mut Vec<String>,
197) {
198    for schema_or_ref in schemas.values() {
199        collect_refs_from_schema_or_ref(schema_or_ref, refs);
200    }
201}
202
203fn collect_refs_from_schema_or_ref(schema_or_ref: &openapi::SchemaOrRef, refs: &mut Vec<String>) {
204    match schema_or_ref {
205        openapi::SchemaOrRef::Ref { ref_path } => {
206            if !ref_path.starts_with('#') {
207                refs.push(ref_path.clone());
208            }
209        }
210        openapi::SchemaOrRef::Schema(schema) => {
211            for prop in schema.properties.values() {
212                collect_refs_from_schema_or_ref(prop, refs);
213            }
214            if let Some(items) = &schema.items {
215                collect_refs_from_schema_or_ref(items, refs);
216            }
217            if let Some(all_of) = &schema.all_of {
218                for s in all_of {
219                    collect_refs_from_schema_or_ref(s, refs);
220                }
221            }
222            if let Some(one_of) = &schema.one_of {
223                for s in one_of {
224                    collect_refs_from_schema_or_ref(s, refs);
225                }
226            }
227            if let Some(any_of) = &schema.any_of {
228                for s in any_of {
229                    collect_refs_from_schema_or_ref(s, refs);
230                }
231            }
232            if let Some(ap) = &schema.additional_properties {
233                collect_refs_from_schema_or_ref(ap, refs);
234            }
235        }
236    }
237}
238
239/// Rewrite external $ref paths to local #/components/schemas/ paths.
240fn rewrite_refs(spec: &mut OpenApiSpec, file_part: &str, _pointer_part: &str) {
241    if let Some(components) = &mut spec.components {
242        rewrite_refs_in_schemas(&mut components.schemas, file_part);
243    }
244
245    for (_path, item) in &mut spec.paths {
246        for (_method, op) in item.operations_mut() {
247            for param in &mut op.parameters {
248                if let openapi::ParameterOrRef::Ref { ref_path } = param {
249                    rewrite_single_ref(ref_path, file_part);
250                }
251            }
252            if let Some(openapi::RequestBodyOrRef::Ref { ref_path }) = &mut op.request_body {
253                rewrite_single_ref(ref_path, file_part);
254            }
255            for (_code, resp) in &mut op.responses {
256                if let openapi::ResponseOrRef::Ref { ref_path } = resp {
257                    rewrite_single_ref(ref_path, file_part);
258                }
259            }
260        }
261    }
262}
263
264fn rewrite_refs_in_schemas(
265    schemas: &mut indexmap::IndexMap<String, openapi::SchemaOrRef>,
266    file_part: &str,
267) {
268    for schema_or_ref in schemas.values_mut() {
269        rewrite_refs_in_schema_or_ref(schema_or_ref, file_part);
270    }
271}
272
273fn rewrite_refs_in_schema_or_ref(schema_or_ref: &mut openapi::SchemaOrRef, file_part: &str) {
274    match schema_or_ref {
275        openapi::SchemaOrRef::Ref { ref_path } => {
276            rewrite_single_ref(ref_path, file_part);
277        }
278        openapi::SchemaOrRef::Schema(schema) => {
279            for prop in schema.properties.values_mut() {
280                rewrite_refs_in_schema_or_ref(prop, file_part);
281            }
282            if let Some(items) = &mut schema.items {
283                rewrite_refs_in_schema_or_ref(items, file_part);
284            }
285            if let Some(all_of) = &mut schema.all_of {
286                for s in all_of {
287                    rewrite_refs_in_schema_or_ref(s, file_part);
288                }
289            }
290            if let Some(one_of) = &mut schema.one_of {
291                for s in one_of {
292                    rewrite_refs_in_schema_or_ref(s, file_part);
293                }
294            }
295            if let Some(any_of) = &mut schema.any_of {
296                for s in any_of {
297                    rewrite_refs_in_schema_or_ref(s, file_part);
298                }
299            }
300            if let Some(ap) = &mut schema.additional_properties {
301                rewrite_refs_in_schema_or_ref(ap, file_part);
302            }
303        }
304    }
305}
306
307/// Rewrite a single external $ref like "./models.yaml#/components/schemas/Pet"
308/// to a local $ref "#/components/schemas/Pet".
309fn rewrite_single_ref(ref_path: &mut String, file_part: &str) {
310    if ref_path.starts_with(file_part)
311        && let Some(hash_pos) = ref_path.find('#')
312    {
313        *ref_path = ref_path[hash_pos..].to_string();
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn parse_petstore_version() {
323        let yaml = r#"
324openapi: "3.0.3"
325info:
326  title: Petstore
327  version: "1.0.0"
328paths: {}
329"#;
330        let spec = parse(yaml).unwrap();
331        assert_eq!(spec.openapi, "3.0.3");
332        assert_eq!(spec.info.title, "Petstore");
333    }
334
335    #[test]
336    fn reject_unsupported_version() {
337        let yaml = r#"
338openapi: "2.0"
339info:
340  title: Old
341  version: "1.0.0"
342paths: {}
343"#;
344        assert!(parse(yaml).is_err());
345    }
346}