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