Skip to main content

oapi_codegen/
loader.rs

1//! Loading OpenAPI documents and resolving `$ref`s.
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::path::Path;
6use std::path::PathBuf;
7use std::rc::Rc;
8
9use indexmap::IndexMap;
10use openapiv3::OpenAPI;
11use openapiv3::Parameter;
12use openapiv3::ReferenceOr;
13use openapiv3::RequestBody;
14use openapiv3::Response;
15use openapiv3::Schema;
16
17use crate::error::Error;
18use crate::error::Result;
19
20/// Maximum `$ref` chain length before bailing out (cycle guard).
21const MAX_REF_DEPTH: usize = 32;
22
23/// The OpenAPI minor versions the generator reads. Every parsed document must
24/// declare a patch release of one of them.
25///
26/// This is a list so that adding a version is one entry here. The gate and its
27/// message both read it, so neither states a version of its own.
28const SUPPORTED_SPEC_VERSIONS: [&str; 1] = ["3.0"];
29
30/// Top-level keys that carry operations the generator cannot emit. A document
31/// that declares one is rejected, because ignoring it emits no handler for any
32/// operation inside it and reads as a document that declares none.
33///
34/// `webhooks` is a 3.1 key, so today the version gate rejects such a document
35/// first and this list only reaches a 3.0 document that declares the key anyway.
36/// Such a document is still ambiguous, and the generator does not guess. The check
37/// stands on its own once a version that defines the key is supported.
38const UNSUPPORTED_TOP_LEVEL_KEYS: [&str; 1] = ["webhooks"];
39
40/// Shared empty schema map returned when a document has no components.
41static EMPTY_SCHEMAS: std::sync::OnceLock<IndexMap<String, ReferenceOr<Schema>>> = std::sync::OnceLock::new();
42
43/// Shared empty security-scheme map returned when a document has no components.
44static EMPTY_SECURITY_SCHEMES: std::sync::OnceLock<IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>>> =
45    std::sync::OnceLock::new();
46
47/// A resolved structural object plus the referenced file it came from.
48#[derive(Debug, Clone)]
49pub struct Resolved<T> {
50    /// The concrete, owned object.
51    pub value: T,
52    /// The referenced file the object was ultimately resolved from, written
53    /// exactly as it appears in the `$ref` (the `import-mapping` key), or `None`
54    /// for a same-document / inline object.
55    pub origin: Option<String>,
56}
57
58/// A loaded OpenAPI document plus its source path (for diagnostics).
59#[derive(Debug)]
60pub struct Spec {
61    inner: OpenAPI,
62    source: PathBuf,
63    docs: RefCell<HashMap<PathBuf, Rc<OpenAPI>>>,
64}
65
66impl Spec {
67    /// Load and parse an OpenAPI document from a YAML or JSON file.
68    pub fn load(path: &Path) -> Result<Self> {
69        let text = std::fs::read_to_string(path).map_err(|source| {
70            return Error::ReadSpec {
71                path: path.display().to_string(),
72                source,
73            };
74        })?;
75        let document = path.display().to_string();
76        // Parse to a `Value` first, then into `OpenAPI` from that one tree. The
77        // typed form drops every key it does not know, so a key such as
78        // `webhooks:` is only visible here. This costs no second parse of the
79        // text.
80        let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
81            return Error::ParseSpec {
82                path: document.clone(),
83                source,
84            };
85        })?;
86        // Both checks read the untyped tree, and both run before the typed parse.
87        // The version gate must, because a 3.1-only construct fails that parse
88        // with a message that names a YAML shape and not a version.
89        check_spec_version(&document, &value)?;
90        check_top_level_keys(&value)?;
91        let inner: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
92            return Error::ParseSpec {
93                path: document.clone(),
94                source,
95            };
96        })?;
97        return Ok(Spec {
98            inner,
99            source: path.to_path_buf(),
100            docs: RefCell::new(HashMap::new()),
101        });
102    }
103
104    /// Construct a spec directly from an already-parsed document (test helper).
105    pub fn from_parts(inner: OpenAPI, source: PathBuf) -> Self {
106        return Spec {
107            inner,
108            source,
109            docs: RefCell::new(HashMap::new()),
110        };
111    }
112
113    /// Parse (once, cached) the file referenced by a cross-file `$ref`,
114    /// resolving `file` relative to the directory containing the main spec.
115    fn document_for(&self, file: &str) -> Result<Rc<OpenAPI>> {
116        let base = self.source.parent().unwrap_or_else(|| {
117            return Path::new(".");
118        });
119        let path = base.join(file);
120        if let Some(doc) = self.docs.borrow().get(&path) {
121            return Ok(Rc::clone(doc));
122        }
123        let text = std::fs::read_to_string(&path).map_err(|source| {
124            return Error::ReadRefFile {
125                file: file.to_owned(),
126                source,
127            };
128        })?;
129        let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
130            return Error::ParseRefFile {
131                file: file.to_owned(),
132                source,
133            };
134        })?;
135        // A referenced file is a document of its own and declares its own
136        // version. A 3.1 fragment pulled into a 3.0 document is the same
137        // ambiguity as a 3.1 root, so the same gate applies, and it applies
138        // before the typed parse for the same reason.
139        check_spec_version(file, &value)?;
140        check_top_level_keys(&value)?;
141        let parsed: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
142            return Error::ParseRefFile {
143                file: file.to_owned(),
144                source,
145            };
146        })?;
147        let doc = Rc::new(parsed);
148        self.docs.borrow_mut().insert(path, Rc::clone(&doc));
149        return Ok(doc);
150    }
151
152    /// The source path the spec was loaded from.
153    pub fn source(&self) -> &Path {
154        return &self.source;
155    }
156
157    /// Apply the configured operation and schema filters, mutating the spec in
158    /// place before lowering (see [`crate::filter`]).
159    pub fn apply_filters(&mut self, opts: &crate::config::OutputOptions) {
160        crate::filter::apply(&mut self.inner, opts);
161    }
162
163    /// The component schemas declared in the document, in document order.
164    pub fn schemas(&self) -> &IndexMap<String, ReferenceOr<Schema>> {
165        let empty = EMPTY_SCHEMAS.get_or_init(IndexMap::new);
166        let schemas = self
167            .inner
168            .components
169            .as_ref()
170            .map(|c| {
171                return &c.schemas;
172            })
173            .unwrap_or(empty);
174        return schemas;
175    }
176
177    /// The paths (operations) declared in the document, in document order.
178    pub fn paths(&self) -> &openapiv3::Paths {
179        return &self.inner.paths;
180    }
181
182    /// The top-level `servers` declared in the document, in document order.
183    pub fn servers(&self) -> &[openapiv3::Server] {
184        return &self.inner.servers;
185    }
186
187    /// The document's global `security` requirements, if declared. An operation
188    /// with no `security` of its own inherits these.
189    pub fn global_security(&self) -> Option<&[openapiv3::SecurityRequirement]> {
190        return self.inner.security.as_deref();
191    }
192
193    /// The security schemes declared under `components.securitySchemes`, in
194    /// document order.
195    pub fn security_schemes(&self) -> &IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>> {
196        let empty = EMPTY_SECURITY_SCHEMES.get_or_init(IndexMap::new);
197        let schemes = self
198            .inner
199            .components
200            .as_ref()
201            .map(|c| {
202                return &c.security_schemes;
203            })
204            .unwrap_or(empty);
205        return schemes;
206    }
207
208    /// Resolve a `#/components/responses/<name>` (possibly cross-file)
209    /// reference to an owned component response plus the file it came from,
210    /// following reference chains within and across documents.
211    pub fn resolve_response(&self, reference: &str) -> Result<Resolved<Response>> {
212        let mut current = reference.to_owned();
213        let mut origin: Option<String> = None;
214        for _ in 0..MAX_REF_DEPTH {
215            if let Some(file) = ref_file_part(&current) {
216                origin = Some(file.to_owned());
217            }
218            let name = ref_component_name(&current, "responses").ok_or_else(|| {
219                return Error::UnsupportedRef {
220                    reference: current.clone(),
221                    reason: "only `#/components/responses/<name>` references are supported".to_owned(),
222                };
223            })?;
224            let entry = self.component_response(origin.as_deref(), &current, name)?;
225            match entry {
226                ReferenceOr::Item(response) => {
227                    return Ok(Resolved {
228                        value: response,
229                        origin,
230                    });
231                }
232                ReferenceOr::Reference { reference } => {
233                    current = reference;
234                }
235            }
236        }
237        return Err(Error::UnresolvedRef(reference.to_owned()));
238    }
239
240    /// Resolve a `#/components/parameters/<name>` (possibly cross-file)
241    /// reference to an owned component parameter plus the file it came from,
242    /// following reference chains within and across documents.
243    pub fn resolve_parameter(&self, reference: &str) -> Result<Resolved<Parameter>> {
244        let mut current = reference.to_owned();
245        let mut origin: Option<String> = None;
246        for _ in 0..MAX_REF_DEPTH {
247            if let Some(file) = ref_file_part(&current) {
248                origin = Some(file.to_owned());
249            }
250            let name = ref_component_name(&current, "parameters").ok_or_else(|| {
251                return Error::UnsupportedRef {
252                    reference: current.clone(),
253                    reason: "only `#/components/parameters/<name>` references are supported".to_owned(),
254                };
255            })?;
256            let entry = self.component_parameter(origin.as_deref(), &current, name)?;
257            match entry {
258                ReferenceOr::Item(parameter) => {
259                    return Ok(Resolved {
260                        value: parameter,
261                        origin,
262                    });
263                }
264                ReferenceOr::Reference { reference } => {
265                    current = reference;
266                }
267            }
268        }
269        return Err(Error::UnresolvedRef(reference.to_owned()));
270    }
271
272    /// Resolve a `#/components/requestBodies/<name>` (possibly cross-file)
273    /// reference to an owned component request body plus the file it came from,
274    /// following reference chains within and across documents.
275    pub fn resolve_request_body(&self, reference: &str) -> Result<Resolved<RequestBody>> {
276        let mut current = reference.to_owned();
277        let mut origin: Option<String> = None;
278        for _ in 0..MAX_REF_DEPTH {
279            if let Some(file) = ref_file_part(&current) {
280                origin = Some(file.to_owned());
281            }
282            let name = ref_component_name(&current, "requestBodies").ok_or_else(|| {
283                return Error::UnsupportedRef {
284                    reference: current.clone(),
285                    reason: "only `#/components/requestBodies/<name>` references are supported".to_owned(),
286                };
287            })?;
288            let entry = self.component_request_body(origin.as_deref(), &current, name)?;
289            match entry {
290                ReferenceOr::Item(body) => {
291                    return Ok(Resolved { value: body, origin });
292                }
293                ReferenceOr::Reference { reference } => {
294                    current = reference;
295                }
296            }
297        }
298        return Err(Error::UnresolvedRef(reference.to_owned()));
299    }
300
301    /// Resolve a `$ref` string to the concrete schema it names, following
302    /// chains of references within this document.
303    pub fn resolve(&self, reference: &str) -> Result<&Schema> {
304        let mut current = reference.to_owned();
305        for _ in 0..MAX_REF_DEPTH {
306            let name = ref_target_name(&current).ok_or_else(|| {
307                return Error::UnsupportedRef {
308                    reference: current.clone(),
309                    reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
310                };
311            })?;
312            let entry = self
313                .schemas()
314                .get(name)
315                .ok_or_else(|| return Error::UnresolvedRef(current.clone()))?;
316            match entry {
317                ReferenceOr::Item(schema) => {
318                    return Ok(schema);
319                }
320                ReferenceOr::Reference { reference } => {
321                    current = reference.clone();
322                }
323            }
324        }
325        return Err(Error::UnresolvedRef(reference.to_owned()));
326    }
327
328    /// Resolve a same-document schema `$ref` to an owned schema, against the
329    /// main document when `origin` is `None` or a referenced document otherwise.
330    ///
331    /// A cross-file (`file#/...`) inner schema ref is intentionally rejected: a
332    /// schema *type* reference is emitted as a named external type through the
333    /// `import-mapping` (see the lowering pass's `schema_ref_type`), never read
334    /// and inlined. This method only resolves refs that stay within one
335    /// document's own `#/components/schemas`.
336    pub fn resolve_schema(&self, origin: Option<&str>, reference: &str) -> Result<Schema> {
337        let mut current = reference.to_owned();
338        for _ in 0..MAX_REF_DEPTH {
339            if ref_file_part(&current).is_some() {
340                return Err(Error::UnsupportedRef {
341                    reference: current.clone(),
342                    reason: "cross-file schema `$ref`s are not supported here".to_owned(),
343                });
344            }
345            let name = ref_component_name(&current, "schemas").ok_or_else(|| {
346                return Error::UnsupportedRef {
347                    reference: current.clone(),
348                    reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
349                };
350            })?;
351            let entry = self.component_schema(origin, &current, name)?;
352            match entry {
353                ReferenceOr::Item(schema) => {
354                    return Ok(schema);
355                }
356                ReferenceOr::Reference { reference } => {
357                    current = reference;
358                }
359            }
360        }
361        return Err(Error::UnresolvedRef(reference.to_owned()));
362    }
363
364    /// Look up a named component in the main document (`origin` is `None`) or a
365    /// referenced document, returning an owned copy. `select` extracts the
366    /// specific component map's entry from a document. `reference` is the full
367    /// `$ref` fragment currently being resolved, reported verbatim in the
368    /// unresolved-reference error so a miss points at the exact ref (kind,
369    /// component, and file). The origin dispatch and error are shared across
370    /// component kinds.
371    fn component_lookup<T>(
372        &self,
373        origin: Option<&str>,
374        reference: &str,
375        select: impl Fn(&OpenAPI) -> Option<ReferenceOr<T>>,
376    ) -> Result<ReferenceOr<T>> {
377        let entry = match origin {
378            None => select(&self.inner),
379            Some(file) => {
380                let doc = self.document_for(file)?;
381                select(doc.as_ref())
382            }
383        };
384        return entry.ok_or_else(|| return Error::UnresolvedRef(reference.to_owned()));
385    }
386
387    /// Look up a component response (see [`Self::component_lookup`]).
388    fn component_response(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Response>> {
389        return self.component_lookup(origin, reference, |doc| {
390            return doc.components.as_ref().and_then(|components| {
391                return components.responses.get(name).cloned();
392            });
393        });
394    }
395
396    /// Look up a component parameter (see [`Self::component_lookup`]).
397    fn component_parameter(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Parameter>> {
398        return self.component_lookup(origin, reference, |doc| {
399            return doc.components.as_ref().and_then(|components| {
400                return components.parameters.get(name).cloned();
401            });
402        });
403    }
404
405    /// Look up a component request body (see [`Self::component_lookup`]).
406    fn component_request_body(
407        &self,
408        origin: Option<&str>,
409        reference: &str,
410        name: &str,
411    ) -> Result<ReferenceOr<RequestBody>> {
412        return self.component_lookup(origin, reference, |doc| {
413            return doc.components.as_ref().and_then(|components| {
414                return components.request_bodies.get(name).cloned();
415            });
416        });
417    }
418
419    /// Look up a component schema (see [`Self::component_lookup`]).
420    fn component_schema(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Schema>> {
421        return self.component_lookup(origin, reference, |doc| {
422            return doc.components.as_ref().and_then(|components| {
423                return components.schemas.get(name).cloned();
424            });
425        });
426    }
427
428    /// The name a schema in a referenced file takes in the crate the
429    /// `import-mapping` points at.
430    ///
431    /// The two runs read the same document, so an `x-rust-name` there reaches
432    /// both. Without this the run that generates the models emits the name the
433    /// key gives, the run that generates the operations emits the schema name,
434    /// and the composed crate does not build.
435    ///
436    /// A miss is an error. The name would otherwise reach the output and name a
437    /// type the other crate never declares.
438    ///
439    /// Two names in the referenced file that give one Rust name are an error
440    /// too. The run that writes the models separates them with a
441    /// `type-name-suffix` it takes from its own config. This run cannot see that
442    /// config, so it emits the plain name and reaches whichever of the two
443    /// schemas kept it. That builds, and it carries the wrong type.
444    pub fn external_schema_name(&self, file: &str, name: &str, reference: &str) -> Result<String> {
445        let entry = self.component_schema(Some(file), reference, name)?;
446        let chosen = external_name_of(&entry, name)?;
447        let doc = self.document_for(file)?;
448        let schemas = doc.components.as_ref().map(|components| return &components.schemas);
449        for (other, other_entry) in schemas.into_iter().flatten() {
450            if other == name {
451                continue;
452            }
453            let taken = external_name_of(other_entry, other)?;
454            let ident = crate::naming::to_ident(&chosen, crate::naming::Case::Pascal);
455            if crate::naming::to_ident(&taken, crate::naming::Case::Pascal).logical() == ident.logical() {
456                return Err(Error::UnsupportedRef {
457                    reference: reference.to_owned(),
458                    reason: format!(
459                        "`{file}` gives `{name}` and `{other}` the one Rust name `{}`",
460                        ident.logical()
461                    ),
462                });
463            }
464        }
465        return Ok(chosen);
466    }
467}
468
469/// The name a schema in a referenced document declares for itself, honouring
470/// `x-rust-name`.
471fn external_name_of(entry: &ReferenceOr<Schema>, name: &str) -> Result<String> {
472    let renamed = match entry {
473        ReferenceOr::Item(schema) => {
474            crate::lower::extension::str_value(&schema.schema_data.extensions, crate::naming::X_RUST_NAME, name)?
475        }
476        ReferenceOr::Reference { .. } => None,
477    };
478    return Ok(renamed.unwrap_or(name).to_owned());
479}
480
481/// Reject a document whose `openapi:` value is not a patch release of a minor
482/// version in [`SUPPORTED_SPEC_VERSIONS`].
483///
484/// The parser behind the generator ignores the `openapi:` value, so without this
485/// check it reads whatever subset of an unsupported document happens to match a
486/// supported dialect, and reports nothing. A document that generates in part and
487/// fails in part is worse than one that fails at once, because the part that
488/// generates looks correct.
489///
490/// The check runs on the untyped tree, before the typed parse. A construct that
491/// only a newer version defines fails that parse with a message about a YAML
492/// shape and not about a version.
493///
494/// A document that declares no `openapi:` key, or declares it as a non-string,
495/// passes here. The typed parse reports that with a message that points at a line.
496fn check_spec_version(document: &str, value: &serde_yaml::Value) -> Result<()> {
497    let Some(version) = value.get("openapi").and_then(serde_yaml::Value::as_str) else {
498        return Ok(());
499    };
500    let version = version.trim();
501    // A patch part is optional in practice, so `3.0` and `3.0.3` both pass. The
502    // dot guards against a future `3.00` reading as `3.0`.
503    let supported = SUPPORTED_SPEC_VERSIONS.iter().any(|minor| {
504        return version == *minor || version.starts_with(&format!("{minor}."));
505    });
506    if supported {
507        return Ok(());
508    }
509    let reads = SUPPORTED_SPEC_VERSIONS
510        .iter()
511        .map(|minor| {
512            return format!("{minor}.x");
513        })
514        .collect::<Vec<_>>()
515        .join(", ");
516    return Err(Error::UnsupportedSpecVersion {
517        document: document.to_owned(),
518        version: version.to_owned(),
519        hint: format!("The generator reads OpenAPI {reads} only."),
520    });
521}
522
523/// Reject a document that declares a top-level key holding operations the
524/// generator cannot emit.
525///
526/// This reads the untyped tree, because the typed `OpenAPI` form drops every key
527/// it does not know and a dropped key cannot be reported. A document that is not
528/// a mapping passes here. The typed parse that follows reports that shape with
529/// its own message, which points at the line.
530fn check_top_level_keys(value: &serde_yaml::Value) -> Result<()> {
531    let Some(mapping) = value.as_mapping() else {
532        return Ok(());
533    };
534    for key in UNSUPPORTED_TOP_LEVEL_KEYS {
535        if mapping.contains_key(serde_yaml::Value::String(key.to_owned())) {
536            return Err(Error::UnsupportedSpecKey {
537                key: key.to_owned(),
538                reason: "declares operations the generator cannot emit".to_owned(),
539                hint: format!(
540                    "Remove `{key}:`, or move its operations under `paths:`. Silently ignoring it emits no handler for any operation it holds."
541                ),
542            });
543        }
544    }
545    return Ok(());
546}
547
548/// Extract the trailing schema name from a *same-document* `$ref`
549/// (`#/components/schemas/Foo` -> `Foo`).
550///
551/// Cross-file references (for example `schemas/x.yaml#/components/schemas/Foo`) yield
552/// `None`: the models pipeline and the same-document `$ref` resolver only handle
553/// in-document schemas, so accepting a cross-file name here will risk emitting a
554/// local `Named` type for what is actually external. The server generator reads
555/// cross-file names via [`ref_component_name`] paired with [`ref_file_part`].
556pub fn ref_target_name(reference: &str) -> Option<&str> {
557    if ref_file_part(reference).is_some() {
558        return None;
559    }
560    return ref_component_name(reference, "schemas");
561}
562
563/// The reason a schema `$ref` at `site` gives no name.
564///
565/// [`ref_target_name`] answers `None` for two different faults, and the remedy
566/// differs. A cross-file ref names a schema and still fails, so a message that
567/// asks the author to reference a schema misleads. Name the fault instead.
568///
569/// `site` reads into the sentence, for example `a property`.
570pub fn schema_ref_reason(reference: &str, site: &str) -> String {
571    if ref_file_part(reference).is_some() {
572        return format!("a cross-file ref does not resolve at {site}");
573    }
574    return format!("{site} must reference `#/components/schemas/<name>`");
575}
576
577/// Extract the trailing component name of the given `kind` (`schemas`,
578/// `responses`, `parameters`, or `requestBodies`) from a (possibly cross-file)
579/// `$ref`.
580pub fn ref_component_name<'a>(reference: &'a str, kind: &str) -> Option<&'a str> {
581    let fragment = reference.split('#').nth(1).unwrap_or(reference);
582    let prefix = match kind {
583        "schemas" => "/components/schemas/",
584        "responses" => "/components/responses/",
585        "parameters" => "/components/parameters/",
586        "requestBodies" => "/components/requestBodies/",
587        _ => return None,
588    };
589    let name = fragment.strip_prefix(prefix)?;
590    if name.is_empty() || name.contains('/') {
591        return None;
592    }
593    return Some(name);
594}
595
596/// The file part of a cross-file `$ref` (the text before `#`), or `None` for a
597/// same-document reference such as `#/components/schemas/Foo`.
598pub fn ref_file_part(reference: &str) -> Option<&str> {
599    return match reference.split_once('#') {
600        Some((file, _fragment)) if !file.is_empty() => Some(file),
601        _ => None,
602    };
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    struct TestDir {
610        path: PathBuf,
611    }
612
613    impl TestDir {
614        fn new(test_name: &str) -> Self {
615            let unique = format!(
616                "oapi-codegen-loader-{test_name}-{}-{}",
617                std::process::id(),
618                std::time::SystemTime::now()
619                    .duration_since(std::time::UNIX_EPOCH)
620                    .expect("system clock should be after Unix epoch")
621                    .as_nanos(),
622            );
623            let path = std::env::temp_dir().join(unique);
624            std::fs::create_dir_all(&path).expect("create test directory");
625            return Self { path };
626        }
627
628        fn write(&self, file: &str, contents: &str) -> PathBuf {
629            let path = self.path.join(file);
630            if let Some(parent) = path.parent() {
631                std::fs::create_dir_all(parent).expect("create parent directory");
632            }
633            std::fs::write(&path, contents).expect("write test file");
634            return path;
635        }
636    }
637
638    impl Drop for TestDir {
639        fn drop(&mut self) {
640            let _ = std::fs::remove_dir_all(&self.path);
641        }
642    }
643
644    fn parse_openapi(yaml: &str) -> OpenAPI {
645        return serde_yaml::from_str(yaml).expect("parse OpenAPI document");
646    }
647
648    fn assert_query_parameter_name(parameter: &Parameter, expected: &str) {
649        match parameter {
650            Parameter::Query { parameter_data, .. } => {
651                assert_eq!(parameter_data.name, expected);
652            }
653            _ => panic!("expected a query parameter"),
654        }
655    }
656
657    fn minimal_doc() -> &'static str {
658        return "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\n";
659    }
660
661    fn shared_parameter_doc(name: &str) -> String {
662        return format!(
663            "openapi: 3.0.3\ninfo:\n  title: shared\n  version: '1'\npaths: {{}}\ncomponents:\n  parameters:\n    PageSize:\n      name: {name}\n      in: query\n      schema:\n        type: integer\n",
664        );
665    }
666
667    #[test]
668    fn ref_target_name_is_same_document_schemas_only() {
669        assert_eq!(ref_target_name("#/components/schemas/Foo"), Some("Foo"));
670        // Cross-file schema refs are rejected here. The server path resolves
671        // them via `ref_component_name` + `ref_file_part` instead.
672        assert_eq!(
673            ref_target_name("schemas/common.yaml#/components/schemas/ErrorResponse"),
674            None
675        );
676        assert_eq!(ref_target_name("#/components/responses/Bar"), None);
677    }
678
679    /// The two faults behind a `None` from [`ref_target_name`] need different
680    /// remedies, so the reason must tell them apart.
681    #[test]
682    fn a_schema_ref_reason_names_the_fault() {
683        assert_eq!(
684            schema_ref_reason("common.yaml#/components/schemas/X", "a property"),
685            "a cross-file ref does not resolve at a property"
686        );
687        assert_eq!(
688            schema_ref_reason("#/components/responses/Bar", "a property"),
689            "a property must reference `#/components/schemas/<name>`"
690        );
691    }
692
693    #[test]
694    fn extracts_component_responses_and_file_parts() {
695        assert_eq!(
696            ref_component_name("#/components/responses/Bar", "responses"),
697            Some("Bar")
698        );
699        assert_eq!(ref_component_name("#/components/schemas/Foo", "responses"), None);
700        assert_eq!(ref_file_part("#/components/schemas/Foo"), None);
701        assert_eq!(
702            ref_file_part("schemas/common.yaml#/components/schemas/ErrorResponse"),
703            Some("schemas/common.yaml"),
704        );
705    }
706
707    #[test]
708    fn ref_component_name_recognizes_parameters_and_request_bodies() {
709        assert_eq!(
710            ref_component_name("#/components/parameters/PageSize", "parameters"),
711            Some("PageSize")
712        );
713        assert_eq!(
714            ref_component_name("#/components/requestBodies/CreateWidget", "requestBodies"),
715            Some("CreateWidget")
716        );
717        assert_eq!(ref_component_name("#/components/schemas/Foo", "parameters"), None);
718    }
719
720    #[test]
721    fn resolves_same_document_component_parameter() {
722        let yaml = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    PageSize:\n      name: pageSize\n      in: query\n      schema:\n        type: integer\n";
723        let doc = parse_openapi(yaml);
724        let spec = Spec::from_parts(doc, std::path::PathBuf::from("inline.yaml"));
725        let param = spec
726            .resolve_parameter("#/components/parameters/PageSize")
727            .expect("resolve");
728        assert!(param.origin.is_none());
729        assert_query_parameter_name(&param.value, "pageSize");
730    }
731
732    #[test]
733    fn errors_on_cross_file_ref_to_missing_file() {
734        let source = std::env::temp_dir().join(format!(
735            "oapi-codegen-loader-missing-{}-{}.yaml",
736            std::process::id(),
737            std::time::SystemTime::now()
738                .duration_since(std::time::UNIX_EPOCH)
739                .expect("system clock should be after Unix epoch")
740                .as_nanos(),
741        ));
742        let spec = Spec::from_parts(parse_openapi(minimal_doc()), source);
743        let result = spec.resolve_parameter("common.yaml#/components/parameters/PageSize");
744        assert!(matches!(result, Err(Error::ReadRefFile { .. })));
745    }
746
747    #[test]
748    fn unresolved_component_error_preserves_the_full_reference() {
749        // A same-document miss reports the full `$ref` fragment (kind + name),
750        // not the bare component name.
751        let yaml = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  parameters: {}\n";
752        let spec = Spec::from_parts(parse_openapi(yaml), std::path::PathBuf::from("inline.yaml"));
753        let result = spec.resolve_parameter("#/components/parameters/Missing");
754        match result {
755            Err(Error::UnresolvedRef(reference)) => {
756                assert_eq!(reference, "#/components/parameters/Missing");
757            }
758            other => panic!("expected UnresolvedRef, got {other:?}"),
759        }
760
761        // A cross-file miss reports the file plus the full fragment.
762        let dir = TestDir::new("unresolved-cross-file");
763        let main = dir.write("main.yaml", minimal_doc());
764        dir.write("shared.yaml", minimal_doc());
765        let spec = Spec::load(&main).expect("load main spec");
766        let result = spec.resolve_parameter("shared.yaml#/components/parameters/Missing");
767        match result {
768            Err(Error::UnresolvedRef(reference)) => {
769                assert_eq!(reference, "shared.yaml#/components/parameters/Missing");
770            }
771            other => panic!("expected UnresolvedRef, got {other:?}"),
772        }
773    }
774
775    #[test]
776    fn document_for_reads_sibling_and_caches() {
777        let dir = TestDir::new("document-for-caches");
778        let main = dir.write("main.yaml", minimal_doc());
779        dir.write("shared.yaml", &shared_parameter_doc("pageSize"));
780
781        let spec = Spec::load(&main).expect("load main spec");
782        let param = spec
783            .resolve_parameter("shared.yaml#/components/parameters/PageSize")
784            .expect("resolve cross-file parameter");
785        assert_eq!(param.origin.as_deref(), Some("shared.yaml"));
786        assert_query_parameter_name(&param.value, "pageSize");
787        assert_eq!(spec.docs.borrow().len(), 1);
788
789        let second = spec
790            .resolve_parameter("shared.yaml#/components/parameters/PageSize")
791            .expect("resolve cached cross-file parameter");
792        assert_eq!(second.origin.as_deref(), Some("shared.yaml"));
793        assert_query_parameter_name(&second.value, "pageSize");
794        assert_eq!(spec.docs.borrow().len(), 1);
795    }
796
797    #[test]
798    fn cross_file_chain_across_two_files_resolves() {
799        let dir = TestDir::new("cross-file-chain");
800        let main = dir.write("main.yaml", minimal_doc());
801        dir.write(
802            "a.yaml",
803            "openapi: 3.0.3\ninfo:\n  title: a\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    X:\n      $ref: \"b.yaml#/components/parameters/Y\"\n",
804        );
805        dir.write(
806            "b.yaml",
807            "openapi: 3.0.3\ninfo:\n  title: b\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    Y:\n      name: cursor\n      in: query\n      schema:\n        type: string\n",
808        );
809
810        let spec = Spec::load(&main).expect("load main spec");
811        let param = spec
812            .resolve_parameter("a.yaml#/components/parameters/X")
813            .expect("resolve cross-file chain");
814        assert_eq!(param.origin.as_deref(), Some("b.yaml"));
815        assert_query_parameter_name(&param.value, "cursor");
816    }
817
818    #[test]
819    fn cross_file_cycle_terminates() {
820        let dir = TestDir::new("cross-file-cycle");
821        let main = dir.write("main.yaml", minimal_doc());
822        dir.write(
823            "a.yaml",
824            "openapi: 3.0.3\ninfo:\n  title: a\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    X:\n      $ref: \"b.yaml#/components/parameters/Y\"\n",
825        );
826        dir.write(
827            "b.yaml",
828            "openapi: 3.0.3\ninfo:\n  title: b\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    Y:\n      $ref: \"a.yaml#/components/parameters/X\"\n",
829        );
830
831        let spec = Spec::load(&main).expect("load main spec");
832        let result = spec.resolve_parameter("a.yaml#/components/parameters/X");
833        assert!(matches!(result, Err(Error::UnresolvedRef(_))));
834    }
835
836    #[test]
837    fn resolve_schema_against_origin() {
838        let dir = TestDir::new("resolve-schema-origin");
839        let main = dir.write("main.yaml", minimal_doc());
840        dir.write(
841            "shared.yaml",
842            "openapi: 3.0.3\ninfo:\n  title: shared\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    PageInfo:\n      type: integer\n      format: int32\n",
843        );
844
845        let spec = Spec::load(&main).expect("load main spec");
846        let schema = spec
847            .resolve_schema(Some("shared.yaml"), "#/components/schemas/PageInfo")
848            .expect("resolve schema from origin");
849        assert!(matches!(
850            schema.schema_kind,
851            openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_))
852        ));
853    }
854}