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
34pub fn parse(input: &str) -> Result<OpenApiSpec, ParseError> {
37 let raw: serde_yaml::Value = serde_yaml::from_str(input)?;
39
40 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
58pub 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
74fn resolve_external_refs(
77 spec: &mut OpenApiSpec,
78 base_dir: &Path,
79 visited_files: &mut HashSet<String>,
80) -> Result<(), ParseError> {
81 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 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; }
97
98 if file_part.contains("://") {
100 continue;
101 }
102
103 let resolved_path = base_dir.join(&file_part);
104
105 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; }
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 let mut ext_spec: OpenApiSpec = serde_yaml::from_str(&ext_content)?;
126
127 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 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_refs(spec, &file_part, &pointer_part);
156 }
157
158 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
168fn 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
251fn 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
321fn 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}