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 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; }
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 let mut ext_spec: OpenApiSpec = serde_yaml::from_str(&ext_content)?;
114
115 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 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_refs(spec, &file_part, &pointer_part);
144 }
145
146 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
156fn 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
239fn 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
307fn 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}