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).
23const 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}
67
68impl Spec {
69    /// Load and parse an OpenAPI document from a YAML or JSON file.
70    pub fn load(path: &Path) -> Result<Self> {
71        let text = std::fs::read_to_string(path).map_err(|source| {
72            return Error::ReadSpec {
73                path: path.display().to_string(),
74                source,
75            };
76        })?;
77        let document = path.display().to_string();
78        // Parse to a `Value` first, then into `OpenAPI` from that one tree. The
79        // typed form drops every key it does not know, so a key such as
80        // `webhooks:` is only visible here. This costs no second parse of the
81        // text.
82        let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
83            return Error::ParseSpec {
84                path: document.clone(),
85                source,
86            };
87        })?;
88        // Both checks read the untyped tree, and both run before the typed parse.
89        // The version gate must, because a 3.1-only construct fails that parse
90        // with a message that names a YAML shape and not a version.
91        check_spec_version(&document, &value)?;
92        check_top_level_keys(&value)?;
93        crate::coverage::check(&document, &value)?;
94        let inner: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
95            return Error::ParseSpec {
96                path: document.clone(),
97                source,
98            };
99        })?;
100        return Ok(Spec {
101            inner,
102            source: path.to_path_buf(),
103            docs: RefCell::new(HashMap::new()),
104        });
105    }
106
107    /// Construct a spec directly from an already-parsed document (test helper).
108    pub fn from_parts(inner: OpenAPI, source: PathBuf) -> Self {
109        return Spec {
110            inner,
111            source,
112            docs: RefCell::new(HashMap::new()),
113        };
114    }
115
116    /// Parse (once, cached) the file referenced by a cross-file `$ref`,
117    /// resolving `file` relative to the directory containing the main spec.
118    fn document_for(&self, file: &str) -> Result<Rc<OpenAPI>> {
119        let base = self.source.parent().unwrap_or_else(|| {
120            return Path::new(".");
121        });
122        let path = base.join(file);
123        if let Some(doc) = self.docs.borrow().get(&path) {
124            return Ok(Rc::clone(doc));
125        }
126        let text = std::fs::read_to_string(&path).map_err(|source| {
127            return Error::ReadRefFile {
128                file: file.to_owned(),
129                source,
130            };
131        })?;
132        let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
133            return Error::ParseRefFile {
134                file: file.to_owned(),
135                source,
136            };
137        })?;
138        // A referenced file is a document of its own and declares its own
139        // version. A 3.1 fragment pulled into a 3.0 document is the same
140        // ambiguity as a 3.1 root, so the same gate applies, and it applies
141        // before the typed parse for the same reason.
142        check_spec_version(file, &value)?;
143        check_top_level_keys(&value)?;
144        crate::coverage::check(file, &value)?;
145        let parsed: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
146            return Error::ParseRefFile {
147                file: file.to_owned(),
148                source,
149            };
150        })?;
151        let doc = Rc::new(parsed);
152        self.docs.borrow_mut().insert(path, Rc::clone(&doc));
153        return Ok(doc);
154    }
155
156    /// The source path the spec was loaded from.
157    pub fn source(&self) -> &Path {
158        return &self.source;
159    }
160
161    /// Apply the configured operation and schema filters, mutating the spec in
162    /// place before lowering (see [`crate::filter`]).
163    pub fn apply_filters(&mut self, opts: &crate::config::OutputOptions) {
164        crate::filter::apply(&mut self.inner, opts);
165    }
166
167    /// The component schemas declared in the document, in document order.
168    pub fn schemas(&self) -> &IndexMap<String, ReferenceOr<Schema>> {
169        let empty = EMPTY_SCHEMAS.get_or_init(IndexMap::new);
170        let schemas = self
171            .inner
172            .components
173            .as_ref()
174            .map(|c| {
175                return &c.schemas;
176            })
177            .unwrap_or(empty);
178        return schemas;
179    }
180
181    /// The paths (operations) declared in the document, in document order.
182    pub fn paths(&self) -> &openapiv3::Paths {
183        return &self.inner.paths;
184    }
185
186    /// The top-level `servers` declared in the document, in document order.
187    pub fn servers(&self) -> &[openapiv3::Server] {
188        return &self.inner.servers;
189    }
190
191    /// The document's global `security` requirements, if declared. An operation
192    /// with no `security` of its own inherits these.
193    pub fn global_security(&self) -> Option<&[openapiv3::SecurityRequirement]> {
194        return self.inner.security.as_deref();
195    }
196
197    /// The security schemes declared under `components.securitySchemes`, in
198    /// document order.
199    pub fn security_schemes(&self) -> &IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>> {
200        let empty = EMPTY_SECURITY_SCHEMES.get_or_init(IndexMap::new);
201        let schemes = self
202            .inner
203            .components
204            .as_ref()
205            .map(|c| {
206                return &c.security_schemes;
207            })
208            .unwrap_or(empty);
209        return schemes;
210    }
211
212    /// Resolve a `#/components/responses/<name>` (possibly cross-file)
213    /// reference to an owned component response plus the file it came from,
214    /// following reference chains within and across documents.
215    pub fn resolve_response(&self, reference: &str) -> Result<Resolved<Response>> {
216        let mut current = reference.to_owned();
217        let mut origin: Option<String> = None;
218        for _ in 0..MAX_REF_DEPTH {
219            if let Some(file) = ref_file_part(&current) {
220                origin = Some(file.to_owned());
221            }
222            let name = ref_component_name(&current, "responses").ok_or_else(|| {
223                return Error::UnsupportedRef {
224                    reference: current.clone(),
225                    reason: "only `#/components/responses/<name>` references are supported".to_owned(),
226                };
227            })?;
228            let entry = self.component_response(origin.as_deref(), &current, name)?;
229            match entry {
230                ReferenceOr::Item(response) => {
231                    return Ok(Resolved {
232                        value: response,
233                        origin,
234                    });
235                }
236                ReferenceOr::Reference { reference } => {
237                    current = reference;
238                }
239            }
240        }
241        return Err(Error::UnresolvedRef(reference.to_owned()));
242    }
243
244    /// Resolve a `#/components/parameters/<name>` (possibly cross-file)
245    /// reference to an owned component parameter plus the file it came from,
246    /// following reference chains within and across documents.
247    pub fn resolve_parameter(&self, reference: &str) -> Result<Resolved<Parameter>> {
248        let mut current = reference.to_owned();
249        let mut origin: Option<String> = None;
250        for _ in 0..MAX_REF_DEPTH {
251            if let Some(file) = ref_file_part(&current) {
252                origin = Some(file.to_owned());
253            }
254            let name = ref_component_name(&current, "parameters").ok_or_else(|| {
255                return Error::UnsupportedRef {
256                    reference: current.clone(),
257                    reason: "only `#/components/parameters/<name>` references are supported".to_owned(),
258                };
259            })?;
260            let entry = self.component_parameter(origin.as_deref(), &current, name)?;
261            match entry {
262                ReferenceOr::Item(parameter) => {
263                    return Ok(Resolved {
264                        value: parameter,
265                        origin,
266                    });
267                }
268                ReferenceOr::Reference { reference } => {
269                    current = reference;
270                }
271            }
272        }
273        return Err(Error::UnresolvedRef(reference.to_owned()));
274    }
275
276    /// Resolve a `#/components/requestBodies/<name>` (possibly cross-file)
277    /// reference to an owned component request body plus the file it came from,
278    /// following reference chains within and across documents.
279    pub fn resolve_request_body(&self, reference: &str) -> Result<Resolved<RequestBody>> {
280        let mut current = reference.to_owned();
281        let mut origin: Option<String> = None;
282        for _ in 0..MAX_REF_DEPTH {
283            if let Some(file) = ref_file_part(&current) {
284                origin = Some(file.to_owned());
285            }
286            let name = ref_component_name(&current, "requestBodies").ok_or_else(|| {
287                return Error::UnsupportedRef {
288                    reference: current.clone(),
289                    reason: "only `#/components/requestBodies/<name>` references are supported".to_owned(),
290                };
291            })?;
292            let entry = self.component_request_body(origin.as_deref(), &current, name)?;
293            match entry {
294                ReferenceOr::Item(body) => {
295                    return Ok(Resolved { value: body, origin });
296                }
297                ReferenceOr::Reference { reference } => {
298                    current = reference;
299                }
300            }
301        }
302        return Err(Error::UnresolvedRef(reference.to_owned()));
303    }
304
305    /// Resolve a `$ref` string to the concrete schema it names, following
306    /// chains of references within this document.
307    pub fn resolve(&self, reference: &str) -> Result<&Schema> {
308        let mut current = reference.to_owned();
309        for _ in 0..MAX_REF_DEPTH {
310            let name = ref_target_name(&current).ok_or_else(|| {
311                return Error::UnsupportedRef {
312                    reference: current.clone(),
313                    reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
314                };
315            })?;
316            let entry = self
317                .schemas()
318                .get(name)
319                .ok_or_else(|| return Error::UnresolvedRef(current.clone()))?;
320            match entry {
321                ReferenceOr::Item(schema) => {
322                    return Ok(schema);
323                }
324                ReferenceOr::Reference { reference } => {
325                    current = reference.clone();
326                }
327            }
328        }
329        return Err(Error::UnresolvedRef(reference.to_owned()));
330    }
331
332    /// Resolve a same-document schema `$ref` to an owned schema, against the
333    /// main document when `origin` is `None` or a referenced document otherwise.
334    ///
335    /// A cross-file (`file#/...`) inner schema ref is intentionally rejected: a
336    /// schema *type* reference is emitted as a named external type through the
337    /// `import-mapping` (see the lowering pass's `schema_ref_type`), never read
338    /// and inlined. This method only resolves refs that stay within one
339    /// document's own `#/components/schemas`.
340    pub fn resolve_schema(&self, origin: Option<&str>, reference: &str) -> Result<Schema> {
341        let mut current = reference.to_owned();
342        for _ in 0..MAX_REF_DEPTH {
343            if ref_file_part(&current).is_some() {
344                return Err(Error::UnsupportedRef {
345                    reference: current.clone(),
346                    reason: "cross-file schema `$ref`s are not supported here".to_owned(),
347                });
348            }
349            let name = ref_component_name(&current, "schemas").ok_or_else(|| {
350                return Error::UnsupportedRef {
351                    reference: current.clone(),
352                    reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
353                };
354            })?;
355            let entry = self.component_schema(origin, &current, name)?;
356            match entry {
357                ReferenceOr::Item(schema) => {
358                    return Ok(schema);
359                }
360                ReferenceOr::Reference { reference } => {
361                    current = reference;
362                }
363            }
364        }
365        return Err(Error::UnresolvedRef(reference.to_owned()));
366    }
367
368    /// Look up a named component in the main document (`origin` is `None`) or a
369    /// referenced document, returning an owned copy. `select` extracts the
370    /// specific component map's entry from a document. `reference` is the full
371    /// `$ref` fragment currently being resolved, reported verbatim in the
372    /// unresolved-reference error so a miss points at the exact ref (kind,
373    /// component, and file). The origin dispatch and error are shared across
374    /// component kinds.
375    fn component_lookup<T>(
376        &self,
377        origin: Option<&str>,
378        reference: &str,
379        select: impl Fn(&OpenAPI) -> Option<ReferenceOr<T>>,
380    ) -> Result<ReferenceOr<T>> {
381        let entry = match origin {
382            None => select(&self.inner),
383            Some(file) => {
384                let doc = self.document_for(file)?;
385                select(doc.as_ref())
386            }
387        };
388        return entry.ok_or_else(|| return Error::UnresolvedRef(reference.to_owned()));
389    }
390
391    /// Look up a component response (see [`Self::component_lookup`]).
392    fn component_response(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Response>> {
393        return self.component_lookup(origin, reference, |doc| {
394            return doc.components.as_ref().and_then(|components| {
395                return components.responses.get(name).cloned();
396            });
397        });
398    }
399
400    /// Look up a component parameter (see [`Self::component_lookup`]).
401    fn component_parameter(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Parameter>> {
402        return self.component_lookup(origin, reference, |doc| {
403            return doc.components.as_ref().and_then(|components| {
404                return components.parameters.get(name).cloned();
405            });
406        });
407    }
408
409    /// Look up a component request body (see [`Self::component_lookup`]).
410    fn component_request_body(
411        &self,
412        origin: Option<&str>,
413        reference: &str,
414        name: &str,
415    ) -> Result<ReferenceOr<RequestBody>> {
416        return self.component_lookup(origin, reference, |doc| {
417            return doc.components.as_ref().and_then(|components| {
418                return components.request_bodies.get(name).cloned();
419            });
420        });
421    }
422
423    /// Look up a component schema (see [`Self::component_lookup`]).
424    fn component_schema(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Schema>> {
425        return self.component_lookup(origin, reference, |doc| {
426            return doc.components.as_ref().and_then(|components| {
427                return components.schemas.get(name).cloned();
428            });
429        });
430    }
431
432    /// The name a schema in a referenced file takes in the crate the
433    /// `import-mapping` points at.
434    ///
435    /// The two runs read the same document, so an `x-rust-name` there reaches
436    /// both. Without this the run that generates the models emits the name the
437    /// key gives, the run that generates the operations emits the schema name,
438    /// and the composed crate does not build.
439    ///
440    /// A miss is an error. The name would otherwise reach the output and name a
441    /// type the other crate never declares.
442    ///
443    /// Two names in the referenced file that give one Rust name are an error
444    /// too. The run that writes the models separates them with a
445    /// `type-name-suffix` it takes from its own config. This run cannot see that
446    /// config, so it emits the plain name and reaches whichever of the two
447    /// schemas kept it. That builds, and it carries the wrong type.
448    ///
449    /// A schema the direction pass splits is an error for the same reason. The
450    /// other run emits two names there, and this one cannot tell which of the
451    /// two an `import-mapping` reference means.
452    pub fn external_schema_name(&self, file: &str, name: &str, reference: &str) -> Result<String> {
453        let entry = self.component_schema(Some(file), reference, name)?;
454        let chosen = external_name_of(&entry, name)?;
455        let doc = self.document_for(file)?;
456        let schemas = doc.components.as_ref().map(|components| return &components.schemas);
457        let ident = crate::naming::to_ident(&chosen, crate::naming::Case::Pascal);
458        if direction_split_schemas(&doc).contains(name) {
459            let shape = ident.logical();
460            return Err(Error::UnsupportedRef {
461                reference: reference.to_owned(),
462                reason: format!(
463                    "`{name}` in `{file}` marks a property `readOnly` or `writeOnly`, so the run that \
464                     generates that file emits it as `{shape}{REQUEST_SUFFIX}` and `{shape}{RESPONSE_SUFFIX}`. \
465                     This run cannot tell which of the two an `import-mapping` reference means. Declare \
466                     the schema in this document instead, or drop the mark"
467                ),
468            });
469        }
470        for (other, other_entry) in schemas.into_iter().flatten() {
471            if other == name {
472                continue;
473            }
474            let taken = external_name_of(other_entry, other)?;
475            if crate::naming::to_ident(&taken, crate::naming::Case::Pascal).logical() == ident.logical() {
476                return Err(Error::UnsupportedRef {
477                    reference: reference.to_owned(),
478                    reason: format!(
479                        "`{file}` gives `{name}` and `{other}` the one Rust name `{}`",
480                        ident.logical()
481                    ),
482                });
483            }
484        }
485        return Ok(chosen);
486    }
487}
488
489/// The schemas in `doc` that the direction pass splits into a request shape and
490/// a response shape.
491///
492/// A schema qualifies when a property inside it carries a direction mark, or
493/// when it reaches such a schema through a same-document `$ref`.
494///
495/// A mark on a schema itself does not qualify that schema. The direction pass
496/// splits a model only when one of its properties is directional, so a marked
497/// primitive keeps its one name and only its referrers split.
498///
499/// This reads the document alone, because both runs that compose a crate must
500/// reach the same verdict. Only one of the two holds the operations.
501fn direction_split_schemas(doc: &OpenAPI) -> std::collections::BTreeSet<String> {
502    let mut marked = std::collections::BTreeSet::new();
503    let Some(components) = doc.components.as_ref() else {
504        return marked;
505    };
506
507    let mut directional = std::collections::BTreeSet::new();
508    for (name, entry) in &components.schemas {
509        if let ReferenceOr::Item(schema) = entry
510            && (schema.schema_data.read_only || schema.schema_data.write_only)
511        {
512            directional.insert(name.clone());
513        }
514    }
515
516    let mut referrers: HashMap<String, Vec<String>> = HashMap::new();
517    for (name, entry) in &components.schemas {
518        match entry {
519            ReferenceOr::Item(schema) => {
520                if schema_marks_a_direction(schema, &directional) {
521                    marked.insert(name.clone());
522                }
523                let mut targets = Vec::new();
524                schema_local_refs(schema, &mut targets);
525                for target in targets {
526                    referrers.entry(target).or_default().push(name.clone());
527                }
528            }
529            // A component declared as a `$ref` is an alias. An alias to a split
530            // model splits as well, so it has to reach the closure below.
531            ReferenceOr::Reference { reference } => {
532                if let Some(target) = ref_target_name(reference) {
533                    referrers.entry(target.to_owned()).or_default().push(name.clone());
534                }
535            }
536        }
537    }
538
539    let mut stack: Vec<String> = marked.iter().cloned().collect();
540    while let Some(name) = stack.pop() {
541        let Some(parents) = referrers.get(&name) else {
542            continue;
543        };
544        for parent in parents {
545            if marked.insert(parent.clone()) {
546                stack.push(parent.clone());
547            }
548        }
549    }
550    return marked;
551}
552
553/// Whether a property of `schema`, or of a schema written inside it, carries a
554/// direction mark.
555///
556/// `directional` holds the component schemas that set `readOnly` or `writeOnly`
557/// on themselves. A property that names one of those carries the mark too.
558fn schema_marks_a_direction(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
559    let mut found = declares_a_directional_property(schema, directional);
560    walk_inline_schemas(schema, &mut |inner| {
561        found = found || declares_a_directional_property(inner, directional);
562    });
563    return found;
564}
565
566/// Whether one schema declares a property that only one direction carries.
567fn declares_a_directional_property(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
568    return object_properties(schema).iter().any(|entry| {
569        return match entry {
570            ReferenceOr::Item(inner) => inner.schema_data.read_only || inner.schema_data.write_only,
571            ReferenceOr::Reference { reference } => {
572                return ref_file_part(reference).is_none()
573                    && ref_component_name(reference, "schemas").is_some_and(|name| return directional.contains(name));
574            }
575        };
576    });
577}
578
579/// The schemas one schema declares as properties.
580fn object_properties(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
581    let unbox = |entry: &ReferenceOr<Box<Schema>>| {
582        return match entry {
583            ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
584            ReferenceOr::Reference { reference } => ReferenceOr::Reference {
585                reference: reference.clone(),
586            },
587        };
588    };
589    return match &schema.schema_kind {
590        openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
591            return object.properties.values().map(&unbox).collect();
592        }
593        openapiv3::SchemaKind::Any(any) => return any.properties.values().map(&unbox).collect(),
594        openapiv3::SchemaKind::Type(_)
595        | openapiv3::SchemaKind::OneOf { .. }
596        | openapiv3::SchemaKind::AllOf { .. }
597        | openapiv3::SchemaKind::AnyOf { .. }
598        | openapiv3::SchemaKind::Not { .. } => Vec::new(),
599    };
600}
601
602/// The same-document component schemas a schema names, at any depth.
603fn schema_local_refs(schema: &Schema, out: &mut Vec<String>) {
604    let mut collect = |entry: &ReferenceOr<Schema>| {
605        if let ReferenceOr::Reference { reference } = entry
606            && ref_file_part(reference).is_none()
607            && let Some(name) = ref_component_name(reference, "schemas")
608        {
609            out.push(name.to_owned());
610        }
611    };
612    for entry in inline_members(schema) {
613        collect(&entry);
614    }
615    walk_inline_schemas(schema, &mut |inner| {
616        for entry in inline_members(inner) {
617            collect(&entry);
618        }
619    });
620}
621
622/// Apply `visit` to every schema written inline within `schema`, at any depth.
623fn walk_inline_schemas(schema: &Schema, visit: &mut impl FnMut(&Schema)) {
624    for entry in inline_members(schema) {
625        if let ReferenceOr::Item(inner) = entry {
626            visit(&inner);
627            walk_inline_schemas(&inner, visit);
628        }
629    }
630}
631
632/// The schemas one schema holds directly: its properties, its element type, its
633/// `additionalProperties`, and its composition members.
634fn inline_members(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
635    let unbox = |entry: &ReferenceOr<Box<Schema>>| {
636        return match entry {
637            ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
638            ReferenceOr::Reference { reference } => ReferenceOr::Reference {
639                reference: reference.clone(),
640            },
641        };
642    };
643    let additional = |entry: &Option<openapiv3::AdditionalProperties>| {
644        return match entry {
645            Some(openapiv3::AdditionalProperties::Schema(inner)) => vec![(**inner).clone()],
646            Some(openapiv3::AdditionalProperties::Any(_)) | None => Vec::new(),
647        };
648    };
649    let mut members = Vec::new();
650    match &schema.schema_kind {
651        openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
652            members.extend(object.properties.values().map(&unbox));
653            members.extend(additional(&object.additional_properties));
654        }
655        openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
656            members.extend(array.items.as_ref().map(unbox));
657        }
658        openapiv3::SchemaKind::Type(_) => {}
659        openapiv3::SchemaKind::OneOf { one_of } => members.extend(one_of.iter().cloned()),
660        openapiv3::SchemaKind::AllOf { all_of } => members.extend(all_of.iter().cloned()),
661        openapiv3::SchemaKind::AnyOf { any_of } => members.extend(any_of.iter().cloned()),
662        openapiv3::SchemaKind::Not { not } => members.push((**not).clone()),
663        openapiv3::SchemaKind::Any(any) => {
664            members.extend(any.properties.values().map(&unbox));
665            members.extend(additional(&any.additional_properties));
666            members.extend(any.items.as_ref().map(unbox));
667            members.extend(any.one_of.iter().cloned());
668            members.extend(any.all_of.iter().cloned());
669            members.extend(any.any_of.iter().cloned());
670            members.extend(any.not.as_ref().map(|not| return (**not).clone()));
671        }
672    }
673    return members;
674}
675
676/// The name a schema in a referenced document declares for itself, honouring
677/// `x-rust-name`.
678fn external_name_of(entry: &ReferenceOr<Schema>, name: &str) -> Result<String> {
679    let renamed = match entry {
680        ReferenceOr::Item(schema) => {
681            crate::lower::extension::str_value(&schema.schema_data.extensions, crate::naming::X_RUST_NAME, name)?
682        }
683        ReferenceOr::Reference { .. } => None,
684    };
685    return Ok(renamed.unwrap_or(name).to_owned());
686}
687
688/// Reject a document whose `openapi:` value is not a patch release of a minor
689/// version in [`SUPPORTED_SPEC_VERSIONS`].
690///
691/// The parser behind the generator ignores the `openapi:` value, so without this
692/// check it reads whatever subset of an unsupported document happens to match a
693/// supported dialect, and reports nothing. A document that generates in part and
694/// fails in part is worse than one that fails at once, because the part that
695/// generates looks correct.
696///
697/// The check runs on the untyped tree, before the typed parse. A construct that
698/// only a newer version defines fails that parse with a message about a YAML
699/// shape and not about a version.
700///
701/// A document that declares no `openapi:` key, or declares it as a non-string,
702/// passes here. The typed parse reports that with a message that points at a line.
703fn check_spec_version(document: &str, value: &serde_yaml::Value) -> Result<()> {
704    let Some(version) = value.get("openapi").and_then(serde_yaml::Value::as_str) else {
705        return Ok(());
706    };
707    let version = version.trim();
708    // A patch part is optional in practice, so `3.0` and `3.0.3` both pass. The
709    // dot guards against a future `3.00` reading as `3.0`.
710    let supported = SUPPORTED_SPEC_VERSIONS.iter().any(|minor| {
711        return version == *minor || version.starts_with(&format!("{minor}."));
712    });
713    if supported {
714        return Ok(());
715    }
716    let reads = SUPPORTED_SPEC_VERSIONS
717        .iter()
718        .map(|minor| {
719            return format!("{minor}.x");
720        })
721        .collect::<Vec<_>>()
722        .join(", ");
723    return Err(Error::UnsupportedSpecVersion {
724        document: document.to_owned(),
725        version: version.to_owned(),
726        hint: format!("The generator reads OpenAPI {reads} only."),
727    });
728}
729
730/// Reject a document that declares a top-level key holding operations the
731/// generator cannot emit.
732///
733/// This reads the untyped tree, because the typed `OpenAPI` form drops every key
734/// it does not know and a dropped key cannot be reported. A document that is not
735/// a mapping passes here. The typed parse that follows reports that shape with
736/// its own message, which points at the line.
737fn check_top_level_keys(value: &serde_yaml::Value) -> Result<()> {
738    let Some(mapping) = value.as_mapping() else {
739        return Ok(());
740    };
741    for key in UNSUPPORTED_TOP_LEVEL_KEYS {
742        if mapping.contains_key(serde_yaml::Value::String(key.to_owned())) {
743            return Err(Error::UnsupportedSpecKey {
744                key: key.to_owned(),
745                reason: "declares operations the generator cannot emit".to_owned(),
746                hint: format!(
747                    "Remove `{key}:`, or move its operations under `paths:`. Silently ignoring it emits no handler for any operation it holds."
748                ),
749            });
750        }
751    }
752    return Ok(());
753}
754
755/// Extract the trailing schema name from a *same-document* `$ref`
756/// (`#/components/schemas/Foo` -> `Foo`).
757///
758/// Cross-file references (for example `schemas/x.yaml#/components/schemas/Foo`) yield
759/// `None`: the models pipeline and the same-document `$ref` resolver only handle
760/// in-document schemas, so accepting a cross-file name here will risk emitting a
761/// local `Named` type for what is actually external. The server generator reads
762/// cross-file names via [`ref_component_name`] paired with [`ref_file_part`].
763pub fn ref_target_name(reference: &str) -> Option<&str> {
764    if ref_file_part(reference).is_some() {
765        return None;
766    }
767    return ref_component_name(reference, "schemas");
768}
769
770/// The reason a schema `$ref` at `site` gives no name.
771///
772/// [`ref_target_name`] answers `None` for two different faults, and the remedy
773/// differs. A cross-file ref names a schema and still fails, so a message that
774/// asks the author to reference a schema misleads. Name the fault instead.
775///
776/// `site` reads into the sentence, for example `a property`.
777pub fn schema_ref_reason(reference: &str, site: &str) -> String {
778    if ref_file_part(reference).is_some() {
779        return format!("a cross-file ref does not resolve at {site}");
780    }
781    return format!("{site} must reference `#/components/schemas/<name>`");
782}
783
784/// Extract the trailing component name of the given `kind` (`schemas`,
785/// `responses`, `parameters`, or `requestBodies`) from a (possibly cross-file)
786/// `$ref`.
787pub fn ref_component_name<'a>(reference: &'a str, kind: &str) -> Option<&'a str> {
788    let fragment = reference.split('#').nth(1).unwrap_or(reference);
789    let prefix = match kind {
790        "schemas" => "/components/schemas/",
791        "responses" => "/components/responses/",
792        "parameters" => "/components/parameters/",
793        "requestBodies" => "/components/requestBodies/",
794        _ => return None,
795    };
796    let name = fragment.strip_prefix(prefix)?;
797    if name.is_empty() || name.contains('/') {
798        return None;
799    }
800    return Some(name);
801}
802
803/// The file part of a cross-file `$ref` (the text before `#`), or `None` for a
804/// same-document reference such as `#/components/schemas/Foo`.
805pub fn ref_file_part(reference: &str) -> Option<&str> {
806    return match reference.split_once('#') {
807        Some((file, _fragment)) if !file.is_empty() => Some(file),
808        _ => None,
809    };
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    struct TestDir {
817        path: PathBuf,
818    }
819
820    impl TestDir {
821        fn new(test_name: &str) -> Self {
822            let unique = format!(
823                "oapi-codegen-loader-{test_name}-{}-{}",
824                std::process::id(),
825                std::time::SystemTime::now()
826                    .duration_since(std::time::UNIX_EPOCH)
827                    .expect("system clock should be after Unix epoch")
828                    .as_nanos(),
829            );
830            let path = std::env::temp_dir().join(unique);
831            std::fs::create_dir_all(&path).expect("create test directory");
832            return Self { path };
833        }
834
835        fn write(&self, file: &str, contents: &str) -> PathBuf {
836            let path = self.path.join(file);
837            if let Some(parent) = path.parent() {
838                std::fs::create_dir_all(parent).expect("create parent directory");
839            }
840            std::fs::write(&path, contents).expect("write test file");
841            return path;
842        }
843    }
844
845    impl Drop for TestDir {
846        fn drop(&mut self) {
847            let _ = std::fs::remove_dir_all(&self.path);
848        }
849    }
850
851    fn parse_openapi(yaml: &str) -> OpenAPI {
852        return serde_yaml::from_str(yaml).expect("parse OpenAPI document");
853    }
854
855    fn assert_query_parameter_name(parameter: &Parameter, expected: &str) {
856        match parameter {
857            Parameter::Query { parameter_data, .. } => {
858                assert_eq!(parameter_data.name, expected);
859            }
860            _ => panic!("expected a query parameter"),
861        }
862    }
863
864    fn minimal_doc() -> &'static str {
865        return "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\n";
866    }
867
868    fn shared_parameter_doc(name: &str) -> String {
869        return format!(
870            "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",
871        );
872    }
873
874    #[test]
875    fn ref_target_name_is_same_document_schemas_only() {
876        assert_eq!(ref_target_name("#/components/schemas/Foo"), Some("Foo"));
877        // Cross-file schema refs are rejected here. The server path resolves
878        // them via `ref_component_name` + `ref_file_part` instead.
879        assert_eq!(
880            ref_target_name("schemas/common.yaml#/components/schemas/ErrorResponse"),
881            None
882        );
883        assert_eq!(ref_target_name("#/components/responses/Bar"), None);
884    }
885
886    /// The two faults behind a `None` from [`ref_target_name`] need different
887    /// remedies, so the reason must tell them apart.
888    #[test]
889    fn a_schema_ref_reason_names_the_fault() {
890        assert_eq!(
891            schema_ref_reason("common.yaml#/components/schemas/X", "a property"),
892            "a cross-file ref does not resolve at a property"
893        );
894        assert_eq!(
895            schema_ref_reason("#/components/responses/Bar", "a property"),
896            "a property must reference `#/components/schemas/<name>`"
897        );
898    }
899
900    #[test]
901    fn extracts_component_responses_and_file_parts() {
902        assert_eq!(
903            ref_component_name("#/components/responses/Bar", "responses"),
904            Some("Bar")
905        );
906        assert_eq!(ref_component_name("#/components/schemas/Foo", "responses"), None);
907        assert_eq!(ref_file_part("#/components/schemas/Foo"), None);
908        assert_eq!(
909            ref_file_part("schemas/common.yaml#/components/schemas/ErrorResponse"),
910            Some("schemas/common.yaml"),
911        );
912    }
913
914    #[test]
915    fn ref_component_name_recognizes_parameters_and_request_bodies() {
916        assert_eq!(
917            ref_component_name("#/components/parameters/PageSize", "parameters"),
918            Some("PageSize")
919        );
920        assert_eq!(
921            ref_component_name("#/components/requestBodies/CreateWidget", "requestBodies"),
922            Some("CreateWidget")
923        );
924        assert_eq!(ref_component_name("#/components/schemas/Foo", "parameters"), None);
925    }
926
927    #[test]
928    fn resolves_same_document_component_parameter() {
929        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";
930        let doc = parse_openapi(yaml);
931        let spec = Spec::from_parts(doc, std::path::PathBuf::from("inline.yaml"));
932        let param = spec
933            .resolve_parameter("#/components/parameters/PageSize")
934            .expect("resolve");
935        assert!(param.origin.is_none());
936        assert_query_parameter_name(&param.value, "pageSize");
937    }
938
939    #[test]
940    fn errors_on_cross_file_ref_to_missing_file() {
941        let source = std::env::temp_dir().join(format!(
942            "oapi-codegen-loader-missing-{}-{}.yaml",
943            std::process::id(),
944            std::time::SystemTime::now()
945                .duration_since(std::time::UNIX_EPOCH)
946                .expect("system clock should be after Unix epoch")
947                .as_nanos(),
948        ));
949        let spec = Spec::from_parts(parse_openapi(minimal_doc()), source);
950        let result = spec.resolve_parameter("common.yaml#/components/parameters/PageSize");
951        assert!(matches!(result, Err(Error::ReadRefFile { .. })));
952    }
953
954    #[test]
955    fn unresolved_component_error_preserves_the_full_reference() {
956        // A same-document miss reports the full `$ref` fragment (kind + name),
957        // not the bare component name.
958        let yaml = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  parameters: {}\n";
959        let spec = Spec::from_parts(parse_openapi(yaml), std::path::PathBuf::from("inline.yaml"));
960        let result = spec.resolve_parameter("#/components/parameters/Missing");
961        match result {
962            Err(Error::UnresolvedRef(reference)) => {
963                assert_eq!(reference, "#/components/parameters/Missing");
964            }
965            other => panic!("expected UnresolvedRef, got {other:?}"),
966        }
967
968        // A cross-file miss reports the file plus the full fragment.
969        let dir = TestDir::new("unresolved-cross-file");
970        let main = dir.write("main.yaml", minimal_doc());
971        dir.write("shared.yaml", minimal_doc());
972        let spec = Spec::load(&main).expect("load main spec");
973        let result = spec.resolve_parameter("shared.yaml#/components/parameters/Missing");
974        match result {
975            Err(Error::UnresolvedRef(reference)) => {
976                assert_eq!(reference, "shared.yaml#/components/parameters/Missing");
977            }
978            other => panic!("expected UnresolvedRef, got {other:?}"),
979        }
980    }
981
982    #[test]
983    fn document_for_reads_sibling_and_caches() {
984        let dir = TestDir::new("document-for-caches");
985        let main = dir.write("main.yaml", minimal_doc());
986        dir.write("shared.yaml", &shared_parameter_doc("pageSize"));
987
988        let spec = Spec::load(&main).expect("load main spec");
989        let param = spec
990            .resolve_parameter("shared.yaml#/components/parameters/PageSize")
991            .expect("resolve cross-file parameter");
992        assert_eq!(param.origin.as_deref(), Some("shared.yaml"));
993        assert_query_parameter_name(&param.value, "pageSize");
994        assert_eq!(spec.docs.borrow().len(), 1);
995
996        let second = spec
997            .resolve_parameter("shared.yaml#/components/parameters/PageSize")
998            .expect("resolve cached cross-file parameter");
999        assert_eq!(second.origin.as_deref(), Some("shared.yaml"));
1000        assert_query_parameter_name(&second.value, "pageSize");
1001        assert_eq!(spec.docs.borrow().len(), 1);
1002    }
1003
1004    #[test]
1005    fn cross_file_chain_across_two_files_resolves() {
1006        let dir = TestDir::new("cross-file-chain");
1007        let main = dir.write("main.yaml", minimal_doc());
1008        dir.write(
1009            "a.yaml",
1010            "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",
1011        );
1012        dir.write(
1013            "b.yaml",
1014            "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",
1015        );
1016
1017        let spec = Spec::load(&main).expect("load main spec");
1018        let param = spec
1019            .resolve_parameter("a.yaml#/components/parameters/X")
1020            .expect("resolve cross-file chain");
1021        assert_eq!(param.origin.as_deref(), Some("b.yaml"));
1022        assert_query_parameter_name(&param.value, "cursor");
1023    }
1024
1025    #[test]
1026    fn cross_file_cycle_terminates() {
1027        let dir = TestDir::new("cross-file-cycle");
1028        let main = dir.write("main.yaml", minimal_doc());
1029        dir.write(
1030            "a.yaml",
1031            "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",
1032        );
1033        dir.write(
1034            "b.yaml",
1035            "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",
1036        );
1037
1038        let spec = Spec::load(&main).expect("load main spec");
1039        let result = spec.resolve_parameter("a.yaml#/components/parameters/X");
1040        assert!(matches!(result, Err(Error::UnresolvedRef(_))));
1041    }
1042
1043    #[test]
1044    fn resolve_schema_against_origin() {
1045        let dir = TestDir::new("resolve-schema-origin");
1046        let main = dir.write("main.yaml", minimal_doc());
1047        dir.write(
1048            "shared.yaml",
1049            "openapi: 3.0.3\ninfo:\n  title: shared\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    PageInfo:\n      type: integer\n      format: int32\n",
1050        );
1051
1052        let spec = Spec::load(&main).expect("load main spec");
1053        let schema = spec
1054            .resolve_schema(Some("shared.yaml"), "#/components/schemas/PageInfo")
1055            .expect("resolve schema from origin");
1056        assert!(matches!(
1057            schema.schema_kind,
1058            openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_))
1059        ));
1060    }
1061
1062    /// A mark on a schema itself splits the schemas that name it, not the schema.
1063    ///
1064    /// The direction pass splits a model only when one of its properties is
1065    /// directional, so a marked primitive stays one type alias. Reporting it as
1066    /// split rejects an `import-mapping` reference that resolves.
1067    #[test]
1068    fn a_schema_level_mark_splits_only_the_referrers() {
1069        let doc: OpenAPI = serde_yaml::from_str(
1070            "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",
1071        )
1072        .expect("parse doc");
1073
1074        let split = direction_split_schemas(&doc);
1075        assert!(split.contains("Holder"), "a property that names a marked schema splits");
1076        assert!(!split.contains("Marked"), "a marked primitive keeps its one name");
1077        assert!(!split.contains("Bystander"), "an unrelated schema keeps its one name");
1078    }
1079
1080    /// A component declared as a `$ref` is an alias, and an alias to a split
1081    /// model splits as well.
1082    ///
1083    /// Missing one emits a reference to a name the models run never writes, so
1084    /// the composed crate does not build.
1085    #[test]
1086    fn an_alias_to_a_split_model_splits_as_well() {
1087        let doc: OpenAPI = serde_yaml::from_str(
1088            "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",
1089        )
1090        .expect("parse doc");
1091
1092        let split = direction_split_schemas(&doc);
1093        assert!(split.contains("Marked"), "the model that declares the mark splits");
1094        assert!(split.contains("Alias"), "an alias to a split model splits");
1095        assert!(!split.contains("Plain"), "an unmarked model keeps its one name");
1096        assert!(
1097            !split.contains("PlainAlias"),
1098            "an alias to an unmarked model keeps its one name"
1099        );
1100    }
1101
1102    /// A mark on a property splits the model that declares it, and every model
1103    /// that reaches it.
1104    #[test]
1105    fn a_property_mark_splits_the_declaring_model_and_its_referrers() {
1106        let doc: OpenAPI = serde_yaml::from_str(
1107            "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",
1108        )
1109        .expect("parse doc");
1110
1111        let split = direction_split_schemas(&doc);
1112        assert!(split.contains("Leaf"), "the model that declares the mark splits");
1113        assert!(split.contains("Parent"), "a model that reaches the mark splits");
1114    }
1115}