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;
19use crate::lower::direction::REQUEST_SUFFIX;
20use crate::lower::direction::RESPONSE_SUFFIX;
21
22/// Maximum `$ref` chain length before bailing out (cycle guard).
23pub(crate) const MAX_REF_DEPTH: usize = 32;
24
25/// The OpenAPI minor versions the generator reads. Every parsed document must
26/// declare a patch release of one of them.
27///
28/// This is a list so that adding a version is one entry here. The gate and its
29/// message both read it, so neither states a version of its own.
30const SUPPORTED_SPEC_VERSIONS: [&str; 1] = ["3.0"];
31
32/// Top-level keys that carry operations the generator cannot emit. A document
33/// that declares one is rejected, because ignoring it emits no handler for any
34/// operation inside it and reads as a document that declares none.
35///
36/// `webhooks` is a 3.1 key, so today the version gate rejects such a document
37/// first and this list only reaches a 3.0 document that declares the key anyway.
38/// Such a document is still ambiguous, and the generator does not guess. The check
39/// stands on its own once a version that defines the key is supported.
40const UNSUPPORTED_TOP_LEVEL_KEYS: [&str; 1] = ["webhooks"];
41
42/// Shared empty schema map returned when a document has no components.
43static EMPTY_SCHEMAS: std::sync::OnceLock<IndexMap<String, ReferenceOr<Schema>>> = std::sync::OnceLock::new();
44
45/// Shared empty security-scheme map returned when a document has no components.
46static EMPTY_SECURITY_SCHEMES: std::sync::OnceLock<IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>>> =
47    std::sync::OnceLock::new();
48
49/// A resolved structural object plus the referenced file it came from.
50#[derive(Debug, Clone)]
51pub struct Resolved<T> {
52    /// The concrete, owned object.
53    pub value: T,
54    /// The referenced file the object was ultimately resolved from, written
55    /// exactly as it appears in the `$ref` (the `import-mapping` key), or `None`
56    /// for a same-document / inline object.
57    pub origin: Option<String>,
58}
59
60/// A loaded OpenAPI document plus its source path (for diagnostics).
61#[derive(Debug)]
62pub struct Spec {
63    inner: OpenAPI,
64    source: PathBuf,
65    docs: RefCell<HashMap<PathBuf, Rc<OpenAPI>>>,
66    /// What the run generates and which operations it keeps. The inspection of
67    /// each document reads this to decide which constraint notes apply: only a
68    /// server checks a query parameter, and only for an operation it keeps.
69    run: crate::coverage::Run,
70    /// The components of other documents that the kept operations reach, each
71    /// as the file and the component's path in it, settled across every
72    /// document before any of them is inspected. A document that is read for a
73    /// `$ref` is inspected with the uses that reach into it.
74    external_uses: Vec<(String, String)>,
75    /// The other documents as written, read for the inspection's lookups.
76    raw: RawDocuments,
77}
78
79/// The documents next to the root, as written, read on demand and kept. The
80/// inspection reads a parameter's identity across files through this, and the
81/// loader settles cross-file uses with it, without inspecting those documents.
82#[derive(Debug)]
83struct RawDocuments {
84    base: PathBuf,
85    read: RefCell<HashMap<PathBuf, Option<Rc<serde_yaml::Value>>>>,
86}
87
88impl RawDocuments {
89    fn new(source: &Path) -> Self {
90        let base = source.parent().unwrap_or_else(|| return Path::new(".")).to_path_buf();
91        return Self {
92            base,
93            read: RefCell::new(HashMap::new()),
94        };
95    }
96
97    /// The path of `file`, as the resolver's cache names it. Two spellings of
98    /// one file, such as `a.yaml` and `./a.yaml`, name the same path.
99    fn path_of(&self, file: &str) -> PathBuf {
100        return self.base.join(file);
101    }
102
103    /// Whether `file` and `other` name the same document.
104    fn same_document(&self, file: &str, other: &str) -> bool {
105        return self.path_of(file) == self.path_of(other);
106    }
107
108    /// The document in `file`, or `None` when it cannot be read or parsed. The
109    /// resolver reports such a file with its own error when it reaches it.
110    fn document(&self, file: &str) -> Option<Rc<serde_yaml::Value>> {
111        let path = self.path_of(file);
112        if let Some(known) = self.read.borrow().get(&path) {
113            return known.clone();
114        }
115        let value = std::fs::read_to_string(&path)
116            .ok()
117            .and_then(|text| return serde_yaml::from_str::<serde_yaml::Value>(&text).ok())
118            .map(Rc::new);
119        self.read.borrow_mut().insert(path, value.clone());
120        return value;
121    }
122
123    /// The component paths in `uses` that name `file`, under any spelling.
124    fn uses_of(&self, uses: &[(String, String)], file: &str) -> Vec<String> {
125        return uses
126            .iter()
127            .filter(|(from, _)| return self.same_document(from, file))
128            .map(|(_, path)| return path.clone())
129            .collect();
130    }
131}
132
133impl crate::coverage::Lookup for RawDocuments {
134    fn component(&self, file: &str, kind: &str, name: &str) -> Option<serde_yaml::Value> {
135        return self.document(file)?.get("components")?.get(kind)?.get(name).cloned();
136    }
137}
138
139/// Every cross-file use that `root` reaches, directly and through the other
140/// documents, settled to a fixed point. The uses of a document decide what its
141/// inspection reads by type, so they must be complete before it runs.
142fn settle_external_uses(
143    mut uses: Vec<(String, String)>,
144    run: &crate::coverage::Run,
145    raw: &RawDocuments,
146) -> Vec<(String, String)> {
147    for _ in 0..MAX_REF_DEPTH {
148        let mut grew = false;
149        let files: std::collections::BTreeSet<String> = uses.iter().map(|(file, _)| return file.clone()).collect();
150        for file in files {
151            let Some(document) = raw.document(&file) else {
152                continue;
153            };
154            let run = crate::coverage::Run {
155                referenced: true,
156                referenced_uses: raw.uses_of(&uses, &file),
157                ..run.clone()
158            };
159            for found in crate::coverage::external_uses(&document, &run, raw) {
160                // A use is the same use under another spelling of its file.
161                let known = uses
162                    .iter()
163                    .any(|(from, path)| return *path == found.1 && raw.same_document(from, &found.0));
164                if !known {
165                    uses.push(found);
166                    grew = true;
167                }
168            }
169        }
170        if !grew {
171            break;
172        }
173    }
174    return uses;
175}
176
177impl Spec {
178    /// Load and parse an OpenAPI document from a YAML or JSON file, for a run
179    /// that generates models only and filters nothing.
180    pub fn load(path: &Path) -> Result<Self> {
181        return Self::load_with(
182            path,
183            &crate::config::Generate::default(),
184            &crate::config::OutputOptions::default(),
185        );
186    }
187
188    /// Load and parse an OpenAPI document from a YAML or JSON file, for a run
189    /// that generates the server when `server` is set and nothing else beside
190    /// the models. [`Spec::load_with`] states the run in full.
191    pub fn load_for(path: &Path, server: bool, options: &crate::config::OutputOptions) -> Result<Self> {
192        let generate = crate::config::Generate {
193            std_http_server: server,
194            ..crate::config::Generate::default()
195        };
196        return Self::load_with(path, &generate, options);
197    }
198
199    /// Load and parse an OpenAPI document from a YAML or JSON file.
200    ///
201    /// `generate` states what the run generates, and `options` holds the
202    /// filters it applies. The inspection reads both: a server checks a query
203    /// parameter's constraints in the generated query struct, and a run that
204    /// lowers operations reads their parameters and bodies by type.
205    pub fn load_with(
206        path: &Path,
207        generate: &crate::config::Generate,
208        options: &crate::config::OutputOptions,
209    ) -> Result<Self> {
210        let run = crate::coverage::Run {
211            server: generate.std_http_server,
212            operations: generate.std_http_server || generate.client,
213            referenced: false,
214            referenced_uses: Vec::new(),
215            filters: options.clone(),
216        };
217        let text = std::fs::read_to_string(path).map_err(|source| {
218            return Error::ReadSpec {
219                path: path.display().to_string(),
220                source,
221            };
222        })?;
223        let document = path.display().to_string();
224        // Parse to a `Value` first, then into `OpenAPI` from that one tree. The
225        // typed form drops every key it does not know, so a key such as
226        // `webhooks:` is only visible here. This costs no second parse of the
227        // text.
228        let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
229            return Error::ParseSpec {
230                path: document.clone(),
231                source,
232            };
233        })?;
234        // Both checks read the untyped tree, and both run before the typed parse.
235        // The version gate must, because a 3.1-only construct fails that parse
236        // with a message that names a YAML shape and not a version.
237        check_spec_version(&document, &value)?;
238        check_top_level_keys(&value)?;
239        let raw = RawDocuments::new(path);
240        let inspected = crate::coverage::check(&document, &value, &run, &raw)?;
241        let external_uses = settle_external_uses(inspected.external_uses, &run, &raw);
242        let mut value = value;
243        crate::coverage::wrap_described_refs(&mut value, &inspected.described_refs);
244        let inner: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
245            return Error::ParseSpec {
246                path: document.clone(),
247                source,
248            };
249        })?;
250        return Ok(Spec {
251            inner,
252            source: path.to_path_buf(),
253            docs: RefCell::new(HashMap::new()),
254            run,
255            external_uses,
256            raw,
257        });
258    }
259
260    /// Construct a spec directly from an already-parsed document (test helper).
261    pub fn from_parts(inner: OpenAPI, source: PathBuf) -> Self {
262        let raw = RawDocuments::new(&source);
263        return Spec {
264            inner,
265            source,
266            docs: RefCell::new(HashMap::new()),
267            run: crate::coverage::Run::default(),
268            external_uses: Vec::new(),
269            raw,
270        };
271    }
272
273    /// Parse (once, cached) the file referenced by a cross-file `$ref`,
274    /// resolving `file` relative to the directory containing the main spec.
275    fn document_for(&self, file: &str) -> Result<Rc<OpenAPI>> {
276        let base = self.source.parent().unwrap_or_else(|| {
277            return Path::new(".");
278        });
279        let path = base.join(file);
280        if let Some(doc) = self.docs.borrow().get(&path) {
281            return Ok(Rc::clone(doc));
282        }
283        let text = std::fs::read_to_string(&path).map_err(|source| {
284            return Error::ReadRefFile {
285                file: file.to_owned(),
286                source,
287            };
288        })?;
289        let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
290            return Error::ParseRefFile {
291                file: file.to_owned(),
292                source,
293            };
294        })?;
295        // A referenced file is a document of its own and declares its own
296        // version. A 3.1 fragment pulled into a 3.0 document is the same
297        // ambiguity as a 3.1 root, so the same gate applies, and it applies
298        // before the typed parse for the same reason.
299        check_spec_version(file, &value)?;
300        check_top_level_keys(&value)?;
301        // The root document's operations reach into this document, directly
302        // or through other documents, so the inspection reads the components
303        // they reach as used, and nothing else. The uses were settled when the
304        // root loaded, so a document read later changes nothing here.
305        let referenced_uses = self.raw.uses_of(&self.external_uses, file);
306        let run = crate::coverage::Run {
307            referenced: true,
308            referenced_uses,
309            ..self.run.clone()
310        };
311        let inspected = crate::coverage::check(file, &value, &run, &self.raw)?;
312        let mut value = value;
313        crate::coverage::wrap_described_refs(&mut value, &inspected.described_refs);
314        let parsed: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
315            return Error::ParseRefFile {
316                file: file.to_owned(),
317                source,
318            };
319        })?;
320        let doc = Rc::new(parsed);
321        self.docs.borrow_mut().insert(path, Rc::clone(&doc));
322        return Ok(doc);
323    }
324
325    /// The source path the spec was loaded from.
326    pub fn source(&self) -> &Path {
327        return &self.source;
328    }
329
330    /// Apply the configured operation and schema filters, mutating the spec in
331    /// place before lowering (see [`crate::filter`]).
332    pub fn apply_filters(&mut self, opts: &crate::config::OutputOptions) {
333        crate::filter::apply(&mut self.inner, opts);
334    }
335
336    /// The component schemas declared in the document, in document order.
337    pub fn schemas(&self) -> &IndexMap<String, ReferenceOr<Schema>> {
338        let empty = EMPTY_SCHEMAS.get_or_init(IndexMap::new);
339        let schemas = self
340            .inner
341            .components
342            .as_ref()
343            .map(|c| {
344                return &c.schemas;
345            })
346            .unwrap_or(empty);
347        return schemas;
348    }
349
350    /// The paths (operations) declared in the document, in document order.
351    pub fn paths(&self) -> &openapiv3::Paths {
352        return &self.inner.paths;
353    }
354
355    /// The top-level `servers` declared in the document, in document order.
356    pub fn servers(&self) -> &[openapiv3::Server] {
357        return &self.inner.servers;
358    }
359
360    /// The document's global `security` requirements, if declared. An operation
361    /// with no `security` of its own inherits these.
362    pub fn global_security(&self) -> Option<&[openapiv3::SecurityRequirement]> {
363        return self.inner.security.as_deref();
364    }
365
366    /// The security schemes declared under `components.securitySchemes`, in
367    /// document order.
368    pub fn security_schemes(&self) -> &IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>> {
369        let empty = EMPTY_SECURITY_SCHEMES.get_or_init(IndexMap::new);
370        let schemes = self
371            .inner
372            .components
373            .as_ref()
374            .map(|c| {
375                return &c.security_schemes;
376            })
377            .unwrap_or(empty);
378        return schemes;
379    }
380
381    /// Resolve a `#/components/responses/<name>` (possibly cross-file)
382    /// reference to an owned component response plus the file it came from,
383    /// following reference chains within and across documents.
384    pub fn resolve_response(&self, reference: &str) -> Result<Resolved<Response>> {
385        let mut current = reference.to_owned();
386        let mut origin: Option<String> = None;
387        for _ in 0..MAX_REF_DEPTH {
388            if let Some(file) = ref_file_part(&current) {
389                origin = Some(file.to_owned());
390            }
391            let name = ref_component_name(&current, "responses").ok_or_else(|| {
392                return Error::UnsupportedRef {
393                    reference: current.clone(),
394                    reason: "only `#/components/responses/<name>` references are supported".to_owned(),
395                };
396            })?;
397            let entry = self.component_response(origin.as_deref(), &current, name)?;
398            match entry {
399                ReferenceOr::Item(response) => {
400                    return Ok(Resolved {
401                        value: response,
402                        origin,
403                    });
404                }
405                ReferenceOr::Reference { reference } => {
406                    current = reference;
407                }
408            }
409        }
410        return Err(Error::UnresolvedRef(reference.to_owned()));
411    }
412
413    /// Resolve a `#/components/parameters/<name>` (possibly cross-file)
414    /// reference to an owned component parameter plus the file it came from,
415    /// following reference chains within and across documents.
416    pub fn resolve_parameter(&self, reference: &str) -> Result<Resolved<Parameter>> {
417        let mut current = reference.to_owned();
418        let mut origin: Option<String> = None;
419        for _ in 0..MAX_REF_DEPTH {
420            if let Some(file) = ref_file_part(&current) {
421                origin = Some(file.to_owned());
422            }
423            let name = ref_component_name(&current, "parameters").ok_or_else(|| {
424                return Error::UnsupportedRef {
425                    reference: current.clone(),
426                    reason: "only `#/components/parameters/<name>` references are supported".to_owned(),
427                };
428            })?;
429            let entry = self.component_parameter(origin.as_deref(), &current, name)?;
430            match entry {
431                ReferenceOr::Item(parameter) => {
432                    return Ok(Resolved {
433                        value: parameter,
434                        origin,
435                    });
436                }
437                ReferenceOr::Reference { reference } => {
438                    current = reference;
439                }
440            }
441        }
442        return Err(Error::UnresolvedRef(reference.to_owned()));
443    }
444
445    /// Resolve a `#/components/requestBodies/<name>` (possibly cross-file)
446    /// reference to an owned component request body plus the file it came from,
447    /// following reference chains within and across documents.
448    pub fn resolve_request_body(&self, reference: &str) -> Result<Resolved<RequestBody>> {
449        let mut current = reference.to_owned();
450        let mut origin: Option<String> = None;
451        for _ in 0..MAX_REF_DEPTH {
452            if let Some(file) = ref_file_part(&current) {
453                origin = Some(file.to_owned());
454            }
455            let name = ref_component_name(&current, "requestBodies").ok_or_else(|| {
456                return Error::UnsupportedRef {
457                    reference: current.clone(),
458                    reason: "only `#/components/requestBodies/<name>` references are supported".to_owned(),
459                };
460            })?;
461            let entry = self.component_request_body(origin.as_deref(), &current, name)?;
462            match entry {
463                ReferenceOr::Item(body) => {
464                    return Ok(Resolved { value: body, origin });
465                }
466                ReferenceOr::Reference { reference } => {
467                    current = reference;
468                }
469            }
470        }
471        return Err(Error::UnresolvedRef(reference.to_owned()));
472    }
473
474    /// Resolve a `$ref` string to the concrete schema it names, following
475    /// chains of references within this document.
476    pub fn resolve(&self, reference: &str) -> Result<&Schema> {
477        let mut current = reference.to_owned();
478        for _ in 0..MAX_REF_DEPTH {
479            let name = ref_target_name(&current).ok_or_else(|| {
480                return Error::UnsupportedRef {
481                    reference: current.clone(),
482                    reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
483                };
484            })?;
485            let entry = self
486                .schemas()
487                .get(name)
488                .ok_or_else(|| return Error::UnresolvedRef(current.clone()))?;
489            match entry {
490                ReferenceOr::Item(schema) => {
491                    return Ok(schema);
492                }
493                ReferenceOr::Reference { reference } => {
494                    current = reference.clone();
495                }
496            }
497        }
498        return Err(Error::UnresolvedRef(reference.to_owned()));
499    }
500
501    /// Resolve a same-document schema `$ref` to an owned schema, against the
502    /// main document when `origin` is `None` or a referenced document otherwise.
503    ///
504    /// A cross-file (`file#/...`) inner schema ref is intentionally rejected: a
505    /// schema *type* reference is emitted as a named external type through the
506    /// `import-mapping` (see the lowering pass's `schema_ref_type`), never read
507    /// and inlined. This method only resolves refs that stay within one
508    /// document's own `#/components/schemas`.
509    pub fn resolve_schema(&self, origin: Option<&str>, reference: &str) -> Result<Schema> {
510        let mut current = reference.to_owned();
511        for _ in 0..MAX_REF_DEPTH {
512            if ref_file_part(&current).is_some() {
513                return Err(Error::UnsupportedRef {
514                    reference: current.clone(),
515                    reason: "cross-file schema `$ref`s are not supported here".to_owned(),
516                });
517            }
518            let name = ref_component_name(&current, "schemas").ok_or_else(|| {
519                return Error::UnsupportedRef {
520                    reference: current.clone(),
521                    reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
522                };
523            })?;
524            let entry = self.component_schema(origin, &current, name)?;
525            match entry {
526                ReferenceOr::Item(schema) => {
527                    return Ok(schema);
528                }
529                ReferenceOr::Reference { reference } => {
530                    current = reference;
531                }
532            }
533        }
534        return Err(Error::UnresolvedRef(reference.to_owned()));
535    }
536
537    /// Look up a named component in the main document (`origin` is `None`) or a
538    /// referenced document, returning an owned copy. `select` extracts the
539    /// specific component map's entry from a document. `reference` is the full
540    /// `$ref` fragment currently being resolved, reported verbatim in the
541    /// unresolved-reference error so a miss points at the exact ref (kind,
542    /// component, and file). The origin dispatch and error are shared across
543    /// component kinds.
544    fn component_lookup<T>(
545        &self,
546        origin: Option<&str>,
547        reference: &str,
548        select: impl Fn(&OpenAPI) -> Option<ReferenceOr<T>>,
549    ) -> Result<ReferenceOr<T>> {
550        let entry = match origin {
551            None => select(&self.inner),
552            Some(file) => {
553                let doc = self.document_for(file)?;
554                select(doc.as_ref())
555            }
556        };
557        return entry.ok_or_else(|| return Error::UnresolvedRef(reference.to_owned()));
558    }
559
560    /// Look up a component response (see [`Self::component_lookup`]).
561    fn component_response(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Response>> {
562        return self.component_lookup(origin, reference, |doc| {
563            return doc.components.as_ref().and_then(|components| {
564                return components.responses.get(name).cloned();
565            });
566        });
567    }
568
569    /// Look up a component parameter (see [`Self::component_lookup`]).
570    fn component_parameter(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Parameter>> {
571        return self.component_lookup(origin, reference, |doc| {
572            return doc.components.as_ref().and_then(|components| {
573                return components.parameters.get(name).cloned();
574            });
575        });
576    }
577
578    /// Look up a component request body (see [`Self::component_lookup`]).
579    fn component_request_body(
580        &self,
581        origin: Option<&str>,
582        reference: &str,
583        name: &str,
584    ) -> Result<ReferenceOr<RequestBody>> {
585        return self.component_lookup(origin, reference, |doc| {
586            return doc.components.as_ref().and_then(|components| {
587                return components.request_bodies.get(name).cloned();
588            });
589        });
590    }
591
592    /// Look up a component schema (see [`Self::component_lookup`]).
593    fn component_schema(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Schema>> {
594        return self.component_lookup(origin, reference, |doc| {
595            return doc.components.as_ref().and_then(|components| {
596                return components.schemas.get(name).cloned();
597            });
598        });
599    }
600
601    /// The name a schema in a referenced file takes in the crate the
602    /// `import-mapping` points at.
603    ///
604    /// The two runs read the same document, so an `x-rust-name` there reaches
605    /// both. Without this the run that generates the models emits the name the
606    /// key gives, the run that generates the operations emits the schema name,
607    /// and the composed crate does not build.
608    ///
609    /// A miss is an error. The name would otherwise reach the output and name a
610    /// type the other crate never declares.
611    ///
612    /// Two names in the referenced file that give one Rust name are an error
613    /// too. The run that writes the models separates them with a
614    /// `type-name-suffix` it takes from its own config. This run cannot see that
615    /// config, so it emits the plain name and reaches whichever of the two
616    /// schemas kept it. That builds, and it carries the wrong type.
617    ///
618    /// A schema the direction pass splits is an error for the same reason. The
619    /// other run emits two names there, and this one cannot tell which of the
620    /// two an `import-mapping` reference means.
621    pub fn external_schema_name(&self, file: &str, name: &str, reference: &str) -> Result<String> {
622        let entry = self.component_schema(Some(file), reference, name)?;
623        let chosen = external_name_of(&entry, name)?;
624        let doc = self.document_for(file)?;
625        let schemas = doc.components.as_ref().map(|components| return &components.schemas);
626        let ident = crate::naming::to_ident(&chosen, crate::naming::Case::Pascal);
627        if direction_split_schemas(&doc).contains(name) {
628            let shape = ident.logical();
629            return Err(Error::UnsupportedRef {
630                reference: reference.to_owned(),
631                reason: format!(
632                    "`{name}` in `{file}` marks a property `readOnly` or `writeOnly`, so the run that \
633                     generates that file emits it as `{shape}{REQUEST_SUFFIX}` and `{shape}{RESPONSE_SUFFIX}`. \
634                     This run cannot tell which of the two an `import-mapping` reference means. Declare \
635                     the schema in this document instead, or drop the mark"
636                ),
637            });
638        }
639        for (other, other_entry) in schemas.into_iter().flatten() {
640            if other == name {
641                continue;
642            }
643            let taken = external_name_of(other_entry, other)?;
644            if crate::naming::to_ident(&taken, crate::naming::Case::Pascal).logical() == ident.logical() {
645                return Err(Error::UnsupportedRef {
646                    reference: reference.to_owned(),
647                    reason: format!(
648                        "`{file}` gives `{name}` and `{other}` the one Rust name `{}`",
649                        ident.logical()
650                    ),
651                });
652            }
653        }
654        return Ok(chosen);
655    }
656}
657
658/// The schemas in `doc` that the direction pass splits into a request shape and
659/// a response shape.
660///
661/// A schema qualifies when a property inside it carries a direction mark, or
662/// when it reaches such a schema through a same-document `$ref`.
663///
664/// A mark on a schema itself does not qualify that schema. The direction pass
665/// splits a model only when one of its properties is directional, so a marked
666/// primitive keeps its one name and only its referrers split.
667///
668/// This reads the document alone, because both runs that compose a crate must
669/// reach the same verdict. Only one of the two holds the operations.
670fn direction_split_schemas(doc: &OpenAPI) -> std::collections::BTreeSet<String> {
671    let mut marked = std::collections::BTreeSet::new();
672    let Some(components) = doc.components.as_ref() else {
673        return marked;
674    };
675
676    // A component that `resolve` follows to a marked schema carries the mark,
677    // whether it is that schema or an alias chain that ends at it. The chain is
678    // followed the way `resolve` follows it, with the same depth, so the two
679    // agree on a chain at the limit.
680    let directional: std::collections::BTreeSet<String> = components
681        .schemas
682        .keys()
683        .filter(|name| {
684            return resolved_component(&components.schemas, name)
685                .is_some_and(|schema| return schema.schema_data.read_only || schema.schema_data.write_only);
686        })
687        .cloned()
688        .collect();
689
690    let mut referrers: HashMap<String, Vec<String>> = HashMap::new();
691    for (name, entry) in &components.schemas {
692        match entry {
693            ReferenceOr::Item(schema) => {
694                if schema_marks_a_direction(schema, &directional) {
695                    marked.insert(name.clone());
696                }
697                let mut targets = Vec::new();
698                schema_local_refs(schema, &mut targets);
699                for target in targets {
700                    referrers.entry(target).or_default().push(name.clone());
701                }
702            }
703            // A component declared as a `$ref` is an alias. An alias to a split
704            // model splits as well, so it has to reach the closure below.
705            ReferenceOr::Reference { reference } => {
706                if let Some(target) = ref_target_name(reference) {
707                    referrers.entry(target.to_owned()).or_default().push(name.clone());
708                }
709            }
710        }
711    }
712
713    let mut stack: Vec<String> = marked.iter().cloned().collect();
714    while let Some(name) = stack.pop() {
715        let Some(parents) = referrers.get(&name) else {
716            continue;
717        };
718        for parent in parents {
719            if marked.insert(parent.clone()) {
720                stack.push(parent.clone());
721            }
722        }
723    }
724    return marked;
725}
726
727/// Whether a property of `schema`, or of a schema written inside it, carries a
728/// direction mark.
729///
730/// `directional` holds the component schemas that set `readOnly` or `writeOnly`
731/// on themselves. A property that names one of those carries the mark too.
732fn schema_marks_a_direction(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
733    let mut found = declares_a_directional_property(schema, directional);
734    walk_inline_schemas(schema, &mut |inner| {
735        found = found || declares_a_directional_property(inner, directional);
736    });
737    return found;
738}
739
740/// Whether one schema declares a property that only one direction carries.
741fn declares_a_directional_property(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
742    let names_a_marked_schema = |reference: &str| {
743        return ref_file_part(reference).is_none()
744            && ref_component_name(reference, "schemas").is_some_and(|name| return directional.contains(name));
745    };
746    return object_properties(schema).iter().any(|entry| {
747        return match entry {
748            // A one-member `allOf` adds keywords to a `$ref` and changes nothing
749            // else, so the mark of the schema it names still applies.
750            ReferenceOr::Item(inner) => {
751                inner.schema_data.read_only
752                    || inner.schema_data.write_only
753                    || single_ref_member(inner).is_some_and(names_a_marked_schema)
754            }
755            ReferenceOr::Reference { reference } => names_a_marked_schema(reference),
756        };
757    });
758}
759
760/// The schema a component name resolves to, through same-document aliases.
761///
762/// This mirrors [`Spec::resolve`]: each name costs one step, the schema itself
763/// included, and a chain that needs more than `MAX_REF_DEPTH` steps resolves to
764/// nothing.
765fn resolved_component<'a>(schemas: &'a IndexMap<String, ReferenceOr<Schema>>, name: &str) -> Option<&'a Schema> {
766    let mut current = name;
767    for _ in 0..MAX_REF_DEPTH {
768        match schemas.get(current)? {
769            ReferenceOr::Item(schema) => return Some(schema),
770            ReferenceOr::Reference { reference } => {
771                if ref_file_part(reference).is_some() {
772                    return None;
773                }
774                current = ref_component_name(reference, "schemas")?;
775            }
776        }
777    }
778    return None;
779}
780
781/// The reference a one-member `allOf` wraps, when that member is a `$ref`.
782///
783/// OpenAPI 3.0 ignores a keyword beside a `$ref`, so a document wraps the
784/// `$ref` in a one-member `allOf` to give it a `description` or `nullable`.
785pub(crate) fn single_ref_member(schema: &Schema) -> Option<&str> {
786    let openapiv3::SchemaKind::AllOf { all_of } = &schema.schema_kind else {
787        return None;
788    };
789    let [ReferenceOr::Reference { reference }] = all_of.as_slice() else {
790        return None;
791    };
792    return Some(reference);
793}
794
795/// The schemas one schema declares as properties.
796fn object_properties(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
797    let unbox = |entry: &ReferenceOr<Box<Schema>>| {
798        return match entry {
799            ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
800            ReferenceOr::Reference { reference } => ReferenceOr::Reference {
801                reference: reference.clone(),
802            },
803        };
804    };
805    return match &schema.schema_kind {
806        openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
807            return object.properties.values().map(&unbox).collect();
808        }
809        openapiv3::SchemaKind::Any(any) => return any.properties.values().map(&unbox).collect(),
810        openapiv3::SchemaKind::Type(_)
811        | openapiv3::SchemaKind::OneOf { .. }
812        | openapiv3::SchemaKind::AllOf { .. }
813        | openapiv3::SchemaKind::AnyOf { .. }
814        | openapiv3::SchemaKind::Not { .. } => Vec::new(),
815    };
816}
817
818/// The same-document component schemas a schema names, at any depth.
819fn schema_local_refs(schema: &Schema, out: &mut Vec<String>) {
820    let mut collect = |entry: &ReferenceOr<Schema>| {
821        if let ReferenceOr::Reference { reference } = entry
822            && ref_file_part(reference).is_none()
823            && let Some(name) = ref_component_name(reference, "schemas")
824        {
825            out.push(name.to_owned());
826        }
827    };
828    for entry in inline_members(schema) {
829        collect(&entry);
830    }
831    walk_inline_schemas(schema, &mut |inner| {
832        for entry in inline_members(inner) {
833            collect(&entry);
834        }
835    });
836}
837
838/// Apply `visit` to every schema written inline within `schema`, at any depth.
839fn walk_inline_schemas(schema: &Schema, visit: &mut impl FnMut(&Schema)) {
840    for entry in inline_members(schema) {
841        if let ReferenceOr::Item(inner) = entry {
842            visit(&inner);
843            walk_inline_schemas(&inner, visit);
844        }
845    }
846}
847
848/// The schemas one schema holds directly: its properties, its element type, its
849/// `additionalProperties`, and its composition members.
850fn inline_members(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
851    let unbox = |entry: &ReferenceOr<Box<Schema>>| {
852        return match entry {
853            ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
854            ReferenceOr::Reference { reference } => ReferenceOr::Reference {
855                reference: reference.clone(),
856            },
857        };
858    };
859    let additional = |entry: &Option<openapiv3::AdditionalProperties>| {
860        return match entry {
861            Some(openapiv3::AdditionalProperties::Schema(inner)) => vec![(**inner).clone()],
862            Some(openapiv3::AdditionalProperties::Any(_)) | None => Vec::new(),
863        };
864    };
865    let mut members = Vec::new();
866    match &schema.schema_kind {
867        openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
868            members.extend(object.properties.values().map(&unbox));
869            members.extend(additional(&object.additional_properties));
870        }
871        openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
872            members.extend(array.items.as_ref().map(unbox));
873        }
874        openapiv3::SchemaKind::Type(_) => {}
875        openapiv3::SchemaKind::OneOf { one_of } => members.extend(one_of.iter().cloned()),
876        openapiv3::SchemaKind::AllOf { all_of } => members.extend(all_of.iter().cloned()),
877        openapiv3::SchemaKind::AnyOf { any_of } => members.extend(any_of.iter().cloned()),
878        openapiv3::SchemaKind::Not { not } => members.push((**not).clone()),
879        openapiv3::SchemaKind::Any(any) => {
880            members.extend(any.properties.values().map(&unbox));
881            members.extend(additional(&any.additional_properties));
882            members.extend(any.items.as_ref().map(unbox));
883            members.extend(any.one_of.iter().cloned());
884            members.extend(any.all_of.iter().cloned());
885            members.extend(any.any_of.iter().cloned());
886            members.extend(any.not.as_ref().map(|not| return (**not).clone()));
887        }
888    }
889    return members;
890}
891
892/// The name a schema in a referenced document declares for itself, honouring
893/// `x-rust-name`.
894fn external_name_of(entry: &ReferenceOr<Schema>, name: &str) -> Result<String> {
895    let renamed = match entry {
896        ReferenceOr::Item(schema) => {
897            crate::lower::extension::str_value(&schema.schema_data.extensions, crate::naming::X_RUST_NAME, name)?
898        }
899        ReferenceOr::Reference { .. } => None,
900    };
901    return Ok(renamed.unwrap_or(name).to_owned());
902}
903
904/// Reject a document whose `openapi:` value is not a patch release of a minor
905/// version in [`SUPPORTED_SPEC_VERSIONS`].
906///
907/// The parser behind the generator ignores the `openapi:` value, so without this
908/// check it reads whatever subset of an unsupported document happens to match a
909/// supported dialect, and reports nothing. A document that generates in part and
910/// fails in part is worse than one that fails at once, because the part that
911/// generates looks correct.
912///
913/// The check runs on the untyped tree, before the typed parse. A construct that
914/// only a newer version defines fails that parse with a message about a YAML
915/// shape and not about a version.
916///
917/// A document that declares no `openapi:` key, or declares it as a non-string,
918/// passes here. The typed parse reports that with a message that points at a line.
919fn check_spec_version(document: &str, value: &serde_yaml::Value) -> Result<()> {
920    let Some(version) = value.get("openapi").and_then(serde_yaml::Value::as_str) else {
921        return Ok(());
922    };
923    let version = version.trim();
924    // A patch part is optional in practice, so `3.0` and `3.0.3` both pass. The
925    // dot guards against a future `3.00` reading as `3.0`.
926    let supported = SUPPORTED_SPEC_VERSIONS.iter().any(|minor| {
927        return version == *minor || version.starts_with(&format!("{minor}."));
928    });
929    if supported {
930        return Ok(());
931    }
932    let reads = SUPPORTED_SPEC_VERSIONS
933        .iter()
934        .map(|minor| {
935            return format!("{minor}.x");
936        })
937        .collect::<Vec<_>>()
938        .join(", ");
939    return Err(Error::UnsupportedSpecVersion {
940        document: document.to_owned(),
941        version: version.to_owned(),
942        hint: format!("The generator reads OpenAPI {reads} only."),
943    });
944}
945
946/// Reject a document that declares a top-level key holding operations the
947/// generator cannot emit.
948///
949/// This reads the untyped tree, because the typed `OpenAPI` form drops every key
950/// it does not know and a dropped key cannot be reported. A document that is not
951/// a mapping passes here. The typed parse that follows reports that shape with
952/// its own message, which points at the line.
953fn check_top_level_keys(value: &serde_yaml::Value) -> Result<()> {
954    let Some(mapping) = value.as_mapping() else {
955        return Ok(());
956    };
957    for key in UNSUPPORTED_TOP_LEVEL_KEYS {
958        if mapping.contains_key(serde_yaml::Value::String(key.to_owned())) {
959            return Err(Error::UnsupportedSpecKey {
960                key: key.to_owned(),
961                reason: "declares operations the generator cannot emit".to_owned(),
962                hint: format!(
963                    "Remove `{key}:`, or move its operations under `paths:`. Silently ignoring it emits no handler for any operation it holds."
964                ),
965            });
966        }
967    }
968    return Ok(());
969}
970
971/// Extract the trailing schema name from a *same-document* `$ref`
972/// (`#/components/schemas/Foo` -> `Foo`).
973///
974/// Cross-file references (for example `schemas/x.yaml#/components/schemas/Foo`) yield
975/// `None`: the models pipeline and the same-document `$ref` resolver only handle
976/// in-document schemas, so accepting a cross-file name here will risk emitting a
977/// local `Named` type for what is actually external. The server generator reads
978/// cross-file names via [`ref_component_name`] paired with [`ref_file_part`].
979pub fn ref_target_name(reference: &str) -> Option<&str> {
980    if ref_file_part(reference).is_some() {
981        return None;
982    }
983    return ref_component_name(reference, "schemas");
984}
985
986/// The reason a schema `$ref` at `site` gives no name.
987///
988/// [`ref_target_name`] answers `None` for two different faults, and the remedy
989/// differs. A cross-file ref names a schema and still fails, so a message that
990/// asks the author to reference a schema misleads. Name the fault instead.
991///
992/// `site` reads into the sentence, for example `a property`.
993pub fn schema_ref_reason(reference: &str, site: &str) -> String {
994    if ref_file_part(reference).is_some() {
995        return format!("a cross-file ref does not resolve at {site}");
996    }
997    return format!("{site} must reference `#/components/schemas/<name>`");
998}
999
1000/// Extract the trailing component name of the given `kind` (`schemas`,
1001/// `responses`, `parameters`, or `requestBodies`) from a (possibly cross-file)
1002/// `$ref`.
1003pub fn ref_component_name<'a>(reference: &'a str, kind: &str) -> Option<&'a str> {
1004    let fragment = reference.split('#').nth(1).unwrap_or(reference);
1005    let prefix = match kind {
1006        "schemas" => "/components/schemas/",
1007        "responses" => "/components/responses/",
1008        "parameters" => "/components/parameters/",
1009        "requestBodies" => "/components/requestBodies/",
1010        _ => return None,
1011    };
1012    let name = fragment.strip_prefix(prefix)?;
1013    if name.is_empty() || name.contains('/') {
1014        return None;
1015    }
1016    return Some(name);
1017}
1018
1019/// The file part of a cross-file `$ref` (the text before `#`), or `None` for a
1020/// same-document reference such as `#/components/schemas/Foo`.
1021pub fn ref_file_part(reference: &str) -> Option<&str> {
1022    return match reference.split_once('#') {
1023        Some((file, _fragment)) if !file.is_empty() => Some(file),
1024        _ => None,
1025    };
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031
1032    struct TestDir {
1033        path: PathBuf,
1034    }
1035
1036    impl TestDir {
1037        fn new(test_name: &str) -> Self {
1038            let unique = format!(
1039                "oapi-codegen-loader-{test_name}-{}-{}",
1040                std::process::id(),
1041                std::time::SystemTime::now()
1042                    .duration_since(std::time::UNIX_EPOCH)
1043                    .expect("system clock should be after Unix epoch")
1044                    .as_nanos(),
1045            );
1046            let path = std::env::temp_dir().join(unique);
1047            std::fs::create_dir_all(&path).expect("create test directory");
1048            return Self { path };
1049        }
1050
1051        fn write(&self, file: &str, contents: &str) -> PathBuf {
1052            let path = self.path.join(file);
1053            if let Some(parent) = path.parent() {
1054                std::fs::create_dir_all(parent).expect("create parent directory");
1055            }
1056            std::fs::write(&path, contents).expect("write test file");
1057            return path;
1058        }
1059    }
1060
1061    impl Drop for TestDir {
1062        fn drop(&mut self) {
1063            let _ = std::fs::remove_dir_all(&self.path);
1064        }
1065    }
1066
1067    fn parse_openapi(yaml: &str) -> OpenAPI {
1068        return serde_yaml::from_str(yaml).expect("parse OpenAPI document");
1069    }
1070
1071    fn assert_query_parameter_name(parameter: &Parameter, expected: &str) {
1072        match parameter {
1073            Parameter::Query { parameter_data, .. } => {
1074                assert_eq!(parameter_data.name, expected);
1075            }
1076            _ => panic!("expected a query parameter"),
1077        }
1078    }
1079
1080    fn minimal_doc() -> &'static str {
1081        return "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\n";
1082    }
1083
1084    fn shared_parameter_doc(name: &str) -> String {
1085        return format!(
1086            "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",
1087        );
1088    }
1089
1090    #[test]
1091    fn ref_target_name_is_same_document_schemas_only() {
1092        assert_eq!(ref_target_name("#/components/schemas/Foo"), Some("Foo"));
1093        // Cross-file schema refs are rejected here. The server path resolves
1094        // them via `ref_component_name` + `ref_file_part` instead.
1095        assert_eq!(
1096            ref_target_name("schemas/common.yaml#/components/schemas/ErrorResponse"),
1097            None
1098        );
1099        assert_eq!(ref_target_name("#/components/responses/Bar"), None);
1100    }
1101
1102    /// The two faults behind a `None` from [`ref_target_name`] need different
1103    /// remedies, so the reason must tell them apart.
1104    #[test]
1105    fn a_schema_ref_reason_names_the_fault() {
1106        assert_eq!(
1107            schema_ref_reason("common.yaml#/components/schemas/X", "a property"),
1108            "a cross-file ref does not resolve at a property"
1109        );
1110        assert_eq!(
1111            schema_ref_reason("#/components/responses/Bar", "a property"),
1112            "a property must reference `#/components/schemas/<name>`"
1113        );
1114    }
1115
1116    #[test]
1117    fn extracts_component_responses_and_file_parts() {
1118        assert_eq!(
1119            ref_component_name("#/components/responses/Bar", "responses"),
1120            Some("Bar")
1121        );
1122        assert_eq!(ref_component_name("#/components/schemas/Foo", "responses"), None);
1123        assert_eq!(ref_file_part("#/components/schemas/Foo"), None);
1124        assert_eq!(
1125            ref_file_part("schemas/common.yaml#/components/schemas/ErrorResponse"),
1126            Some("schemas/common.yaml"),
1127        );
1128    }
1129
1130    #[test]
1131    fn ref_component_name_recognizes_parameters_and_request_bodies() {
1132        assert_eq!(
1133            ref_component_name("#/components/parameters/PageSize", "parameters"),
1134            Some("PageSize")
1135        );
1136        assert_eq!(
1137            ref_component_name("#/components/requestBodies/CreateWidget", "requestBodies"),
1138            Some("CreateWidget")
1139        );
1140        assert_eq!(ref_component_name("#/components/schemas/Foo", "parameters"), None);
1141    }
1142
1143    #[test]
1144    fn resolves_same_document_component_parameter() {
1145        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";
1146        let doc = parse_openapi(yaml);
1147        let spec = Spec::from_parts(doc, std::path::PathBuf::from("inline.yaml"));
1148        let param = spec
1149            .resolve_parameter("#/components/parameters/PageSize")
1150            .expect("resolve");
1151        assert!(param.origin.is_none());
1152        assert_query_parameter_name(&param.value, "pageSize");
1153    }
1154
1155    #[test]
1156    fn errors_on_cross_file_ref_to_missing_file() {
1157        let source = std::env::temp_dir().join(format!(
1158            "oapi-codegen-loader-missing-{}-{}.yaml",
1159            std::process::id(),
1160            std::time::SystemTime::now()
1161                .duration_since(std::time::UNIX_EPOCH)
1162                .expect("system clock should be after Unix epoch")
1163                .as_nanos(),
1164        ));
1165        let spec = Spec::from_parts(parse_openapi(minimal_doc()), source);
1166        let result = spec.resolve_parameter("common.yaml#/components/parameters/PageSize");
1167        assert!(matches!(result, Err(Error::ReadRefFile { .. })));
1168    }
1169
1170    #[test]
1171    fn unresolved_component_error_preserves_the_full_reference() {
1172        // A same-document miss reports the full `$ref` fragment (kind + name),
1173        // not the bare component name.
1174        let yaml = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  parameters: {}\n";
1175        let spec = Spec::from_parts(parse_openapi(yaml), std::path::PathBuf::from("inline.yaml"));
1176        let result = spec.resolve_parameter("#/components/parameters/Missing");
1177        match result {
1178            Err(Error::UnresolvedRef(reference)) => {
1179                assert_eq!(reference, "#/components/parameters/Missing");
1180            }
1181            other => panic!("expected UnresolvedRef, got {other:?}"),
1182        }
1183
1184        // A cross-file miss reports the file plus the full fragment.
1185        let dir = TestDir::new("unresolved-cross-file");
1186        let main = dir.write("main.yaml", minimal_doc());
1187        dir.write("shared.yaml", minimal_doc());
1188        let spec = Spec::load(&main).expect("load main spec");
1189        let result = spec.resolve_parameter("shared.yaml#/components/parameters/Missing");
1190        match result {
1191            Err(Error::UnresolvedRef(reference)) => {
1192                assert_eq!(reference, "shared.yaml#/components/parameters/Missing");
1193            }
1194            other => panic!("expected UnresolvedRef, got {other:?}"),
1195        }
1196    }
1197
1198    #[test]
1199    fn document_for_reads_sibling_and_caches() {
1200        let dir = TestDir::new("document-for-caches");
1201        let main = dir.write("main.yaml", minimal_doc());
1202        dir.write("shared.yaml", &shared_parameter_doc("pageSize"));
1203
1204        let spec = Spec::load(&main).expect("load main spec");
1205        let param = spec
1206            .resolve_parameter("shared.yaml#/components/parameters/PageSize")
1207            .expect("resolve cross-file parameter");
1208        assert_eq!(param.origin.as_deref(), Some("shared.yaml"));
1209        assert_query_parameter_name(&param.value, "pageSize");
1210        assert_eq!(spec.docs.borrow().len(), 1);
1211
1212        let second = spec
1213            .resolve_parameter("shared.yaml#/components/parameters/PageSize")
1214            .expect("resolve cached cross-file parameter");
1215        assert_eq!(second.origin.as_deref(), Some("shared.yaml"));
1216        assert_query_parameter_name(&second.value, "pageSize");
1217        assert_eq!(spec.docs.borrow().len(), 1);
1218    }
1219
1220    /// A use that reaches a document only through another document is known
1221    /// before either document is inspected, whichever is read first.
1222    #[test]
1223    fn external_uses_settle_across_documents_before_inspection() {
1224        let dir = TestDir::new("settled-uses");
1225        let main = dir.write(
1226            "main.yaml",
1227            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths:\n  /a:\n    get:\n      parameters:\n        - $ref: 'a.yaml#/components/parameters/N'\n        - $ref: 'b.yaml#/components/parameters/M'\n      responses: {}\n",
1228        );
1229        dir.write(
1230            "a.yaml",
1231            "openapi: 3.0.3\ninfo:\n  title: a\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    N:\n      name: n\n      in: query\n      schema:\n        type: string\n    K:\n      name: k\n      in: query\n      schema:\n        type: string\n",
1232        );
1233        dir.write(
1234            "b.yaml",
1235            "openapi: 3.0.3\ninfo:\n  title: b\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    M:\n      $ref: 'a.yaml#/components/parameters/K'\n",
1236        );
1237        let generate = crate::config::Generate {
1238            std_http_server: true,
1239            ..crate::config::Generate::default()
1240        };
1241
1242        let spec = Spec::load_with(&main, &generate, &crate::config::OutputOptions::default()).expect("load main spec");
1243
1244        let mut uses = spec.external_uses.clone();
1245        uses.sort();
1246        assert_eq!(
1247            uses,
1248            vec![
1249                ("a.yaml".to_owned(), "/components/parameters/K".to_owned()),
1250                ("a.yaml".to_owned(), "/components/parameters/N".to_owned()),
1251                ("b.yaml".to_owned(), "/components/parameters/M".to_owned()),
1252            ]
1253        );
1254    }
1255
1256    /// Two spellings of one file name one document in the resolver's cache, so
1257    /// the uses under either spelling reach that document's inspection,
1258    /// whichever spelling the resolver meets first.
1259    #[test]
1260    fn uses_under_two_spellings_of_a_file_reach_its_inspection() {
1261        for first in ["shared.yaml", "./shared.yaml"] {
1262            let second = if first == "shared.yaml" {
1263                "./shared.yaml"
1264            } else {
1265                "shared.yaml"
1266            };
1267            let dir = TestDir::new("spellings");
1268            let main = dir.write(
1269                "main.yaml",
1270                &format!(
1271                    "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths:\n  /a:\n    get:\n      parameters:\n        - $ref: '{first}#/components/parameters/N'\n        - $ref: '{second}#/components/parameters/M'\n      responses: {{}}\n"
1272                ),
1273            );
1274            dir.write(
1275                "shared.yaml",
1276                "openapi: 3.0.3\ninfo:\n  title: s\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    N:\n      name: n\n      in: query\n      schema:\n        type: string\n    M:\n      name: m\n      in: query\n      schema:\n        type: string\n",
1277            );
1278            let generate = crate::config::Generate {
1279                std_http_server: true,
1280                ..crate::config::Generate::default()
1281            };
1282
1283            let spec = Spec::load_with(&main, &generate, &crate::config::OutputOptions::default()).expect("load");
1284
1285            let mut uses = spec.raw.uses_of(&spec.external_uses, first);
1286            uses.sort();
1287            assert_eq!(
1288                uses,
1289                vec!["/components/parameters/M", "/components/parameters/N"],
1290                "{first}"
1291            );
1292        }
1293    }
1294
1295    #[test]
1296    fn cross_file_chain_across_two_files_resolves() {
1297        let dir = TestDir::new("cross-file-chain");
1298        let main = dir.write("main.yaml", minimal_doc());
1299        dir.write(
1300            "a.yaml",
1301            "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",
1302        );
1303        dir.write(
1304            "b.yaml",
1305            "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",
1306        );
1307
1308        let spec = Spec::load(&main).expect("load main spec");
1309        let param = spec
1310            .resolve_parameter("a.yaml#/components/parameters/X")
1311            .expect("resolve cross-file chain");
1312        assert_eq!(param.origin.as_deref(), Some("b.yaml"));
1313        assert_query_parameter_name(&param.value, "cursor");
1314    }
1315
1316    #[test]
1317    fn cross_file_cycle_terminates() {
1318        let dir = TestDir::new("cross-file-cycle");
1319        let main = dir.write("main.yaml", minimal_doc());
1320        dir.write(
1321            "a.yaml",
1322            "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",
1323        );
1324        dir.write(
1325            "b.yaml",
1326            "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",
1327        );
1328
1329        let spec = Spec::load(&main).expect("load main spec");
1330        let result = spec.resolve_parameter("a.yaml#/components/parameters/X");
1331        assert!(matches!(result, Err(Error::UnresolvedRef(_))));
1332    }
1333
1334    #[test]
1335    fn resolve_schema_against_origin() {
1336        let dir = TestDir::new("resolve-schema-origin");
1337        let main = dir.write("main.yaml", minimal_doc());
1338        dir.write(
1339            "shared.yaml",
1340            "openapi: 3.0.3\ninfo:\n  title: shared\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    PageInfo:\n      type: integer\n      format: int32\n",
1341        );
1342
1343        let spec = Spec::load(&main).expect("load main spec");
1344        let schema = spec
1345            .resolve_schema(Some("shared.yaml"), "#/components/schemas/PageInfo")
1346            .expect("resolve schema from origin");
1347        assert!(matches!(
1348            schema.schema_kind,
1349            openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_))
1350        ));
1351    }
1352
1353    /// A mark on a schema itself splits the schemas that name it, not the schema.
1354    ///
1355    /// The direction pass splits a model only when one of its properties is
1356    /// directional, so a marked primitive stays one type alias. Reporting it as
1357    /// split rejects an `import-mapping` reference that resolves.
1358    #[test]
1359    fn a_schema_level_mark_splits_only_the_referrers() {
1360        let doc: OpenAPI = serde_yaml::from_str(
1361            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    Marked:\n      type: string\n      readOnly: true\n    Holder:\n      type: object\n      properties:\n        a:\n          $ref: '#/components/schemas/Marked'\n    Bystander:\n      type: object\n      properties:\n        b:\n          type: string\n",
1362        )
1363        .expect("parse doc");
1364
1365        let split = direction_split_schemas(&doc);
1366        assert!(split.contains("Holder"), "a property that names a marked schema splits");
1367        assert!(!split.contains("Marked"), "a marked primitive keeps its one name");
1368        assert!(!split.contains("Bystander"), "an unrelated schema keeps its one name");
1369    }
1370
1371    /// A component declared as a `$ref` is an alias, and an alias to a split
1372    /// model splits as well.
1373    ///
1374    /// Missing one emits a reference to a name the models run never writes, so
1375    /// the composed crate does not build.
1376    #[test]
1377    fn an_alias_to_a_split_model_splits_as_well() {
1378        let doc: OpenAPI = serde_yaml::from_str(
1379            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    Marked:\n      type: object\n      properties:\n        id:\n          type: string\n          readOnly: true\n    Alias:\n      $ref: '#/components/schemas/Marked'\n    Plain:\n      type: object\n      properties:\n        a:\n          type: string\n    PlainAlias:\n      $ref: '#/components/schemas/Plain'\n",
1380        )
1381        .expect("parse doc");
1382
1383        let split = direction_split_schemas(&doc);
1384        assert!(split.contains("Marked"), "the model that declares the mark splits");
1385        assert!(split.contains("Alias"), "an alias to a split model splits");
1386        assert!(!split.contains("Plain"), "an unmarked model keeps its one name");
1387        assert!(
1388            !split.contains("PlainAlias"),
1389            "an alias to an unmarked model keeps its one name"
1390        );
1391    }
1392
1393    /// `resolve` follows an alias to the schema behind it, so a property that
1394    /// names the alias takes the mark of that schema. The split set must agree,
1395    /// or an `import-mapping` run would emit a name the models run never writes.
1396    #[test]
1397    fn an_alias_chain_to_a_marked_schema_splits_the_referrer() {
1398        let doc: OpenAPI = serde_yaml::from_str(
1399            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    Marked:\n      type: string\n      readOnly: true\n    Alias:\n      $ref: '#/components/schemas/Marked'\n    Twice:\n      $ref: '#/components/schemas/Alias'\n    Holder:\n      type: object\n      properties:\n        a:\n          $ref: '#/components/schemas/Alias'\n    Wrapped:\n      type: object\n      properties:\n        a:\n          allOf:\n            - $ref: '#/components/schemas/Twice'\n",
1400        )
1401        .expect("parse doc");
1402
1403        let split = direction_split_schemas(&doc);
1404        assert!(split.contains("Holder"), "a name of an alias to a marked schema splits");
1405        assert!(split.contains("Wrapped"), "a wrapped name of a two-step alias splits");
1406        assert!(
1407            !split.contains("Alias") && !split.contains("Twice"),
1408            "an alias to a primitive keeps its one name"
1409        );
1410    }
1411
1412    /// `resolve` follows at most `MAX_REF_DEPTH` names, the schema itself
1413    /// included. A chain one link longer resolves to nothing in the lowering,
1414    /// which then reads no mark, so the split set must read none either.
1415    #[test]
1416    fn an_alias_chain_at_the_depth_limit_splits_and_one_link_longer_does_not() {
1417        let chain = |links: usize, reversed: bool| {
1418            let mut schemas: Vec<String> = vec!["    Marked:\n      type: string\n      readOnly: true\n".to_owned()];
1419            for index in 1..=links {
1420                let target = if index == links {
1421                    "Marked".to_owned()
1422                } else {
1423                    format!("Alias{}", index + 1)
1424                };
1425                schemas.push(format!(
1426                    "    Alias{index}:\n      $ref: '#/components/schemas/{target}'\n"
1427                ));
1428            }
1429            if reversed {
1430                schemas.reverse();
1431            }
1432            return format!(
1433                "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {{}}\ncomponents:\n  schemas:\n{}    Holder:\n      type: object\n      properties:\n        a:\n          $ref: '#/components/schemas/Alias1'\n",
1434                schemas.concat()
1435            );
1436        };
1437        for (links, splits) in [(MAX_REF_DEPTH - 1, true), (MAX_REF_DEPTH, false)] {
1438            for reversed in [false, true] {
1439                let doc: OpenAPI = serde_yaml::from_str(&chain(links, reversed)).expect("parse doc");
1440                let split = direction_split_schemas(&doc);
1441                assert_eq!(split.contains("Holder"), splits, "{links} links, reversed: {reversed}");
1442            }
1443        }
1444    }
1445
1446    /// A one-member `allOf` around a `$ref` names the same schema as the bare
1447    /// `$ref`, so it splits the same models.
1448    #[test]
1449    fn a_wrapped_reference_to_a_marked_schema_splits_the_referrer() {
1450        let doc: OpenAPI = serde_yaml::from_str(
1451            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    Marked:\n      type: string\n      readOnly: true\n    Plain:\n      type: string\n    Holder:\n      type: object\n      properties:\n        a:\n          allOf:\n            - $ref: '#/components/schemas/Marked'\n          description: wrapped\n    Bystander:\n      type: object\n      properties:\n        b:\n          allOf:\n            - $ref: '#/components/schemas/Plain'\n",
1452        )
1453        .expect("parse doc");
1454
1455        let split = direction_split_schemas(&doc);
1456        assert!(split.contains("Holder"), "a wrapped name of a marked schema splits");
1457        assert!(
1458            !split.contains("Bystander"),
1459            "a wrapped name of a plain schema does not"
1460        );
1461    }
1462
1463    /// A mark on a property splits the model that declares it, and every model
1464    /// that reaches it.
1465    #[test]
1466    fn a_property_mark_splits_the_declaring_model_and_its_referrers() {
1467        let doc: OpenAPI = serde_yaml::from_str(
1468            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    Leaf:\n      type: object\n      properties:\n        id:\n          type: string\n          readOnly: true\n    Parent:\n      type: object\n      properties:\n        leaf:\n          $ref: '#/components/schemas/Leaf'\n",
1469        )
1470        .expect("parse doc");
1471
1472        let split = direction_split_schemas(&doc);
1473        assert!(split.contains("Leaf"), "the model that declares the mark splits");
1474        assert!(split.contains("Parent"), "a model that reaches the mark splits");
1475    }
1476}