1use 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
22pub(crate) const MAX_REF_DEPTH: usize = 32;
24
25const SUPPORTED_SPEC_VERSIONS: [&str; 1] = ["3.0"];
31
32const UNSUPPORTED_TOP_LEVEL_KEYS: [&str; 1] = ["webhooks"];
41
42static EMPTY_SCHEMAS: std::sync::OnceLock<IndexMap<String, ReferenceOr<Schema>>> = std::sync::OnceLock::new();
44
45static EMPTY_SECURITY_SCHEMES: std::sync::OnceLock<IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>>> =
47 std::sync::OnceLock::new();
48
49#[derive(Debug, Clone)]
51pub struct Resolved<T> {
52 pub value: T,
54 pub origin: Option<String>,
58}
59
60#[derive(Debug)]
62pub struct Spec {
63 inner: OpenAPI,
64 source: PathBuf,
65 docs: RefCell<HashMap<PathBuf, Rc<OpenAPI>>>,
66 run: crate::coverage::Run,
70 external_uses: Vec<(String, String)>,
75 raw: RawDocuments,
77}
78
79#[derive(Debug)]
83struct RawDocuments {
84 base: PathBuf,
85 read: RefCell<HashMap<PathBuf, Option<Rc<serde_yaml::Value>>>>,
86}
87
88impl RawDocuments {
89 fn new(source: &Path) -> Self {
90 let base = source.parent().unwrap_or_else(|| return Path::new(".")).to_path_buf();
91 return Self {
92 base,
93 read: RefCell::new(HashMap::new()),
94 };
95 }
96
97 fn path_of(&self, file: &str) -> PathBuf {
100 return self.base.join(file);
101 }
102
103 fn same_document(&self, file: &str, other: &str) -> bool {
105 return self.path_of(file) == self.path_of(other);
106 }
107
108 fn document(&self, file: &str) -> Option<Rc<serde_yaml::Value>> {
111 let path = self.path_of(file);
112 if let Some(known) = self.read.borrow().get(&path) {
113 return known.clone();
114 }
115 let value = std::fs::read_to_string(&path)
116 .ok()
117 .and_then(|text| return serde_yaml::from_str::<serde_yaml::Value>(&text).ok())
118 .map(Rc::new);
119 self.read.borrow_mut().insert(path, value.clone());
120 return value;
121 }
122
123 fn uses_of(&self, uses: &[(String, String)], file: &str) -> Vec<String> {
125 return uses
126 .iter()
127 .filter(|(from, _)| return self.same_document(from, file))
128 .map(|(_, path)| return path.clone())
129 .collect();
130 }
131}
132
133impl crate::coverage::Lookup for RawDocuments {
134 fn component(&self, file: &str, kind: &str, name: &str) -> Option<serde_yaml::Value> {
135 return self.document(file)?.get("components")?.get(kind)?.get(name).cloned();
136 }
137}
138
139fn settle_external_uses(
143 mut uses: Vec<(String, String)>,
144 run: &crate::coverage::Run,
145 raw: &RawDocuments,
146) -> Vec<(String, String)> {
147 for _ in 0..MAX_REF_DEPTH {
148 let mut grew = false;
149 let files: std::collections::BTreeSet<String> = uses.iter().map(|(file, _)| return file.clone()).collect();
150 for file in files {
151 let Some(document) = raw.document(&file) else {
152 continue;
153 };
154 let run = crate::coverage::Run {
155 referenced: true,
156 referenced_uses: raw.uses_of(&uses, &file),
157 ..run.clone()
158 };
159 for found in crate::coverage::external_uses(&document, &run, raw) {
160 let known = uses
162 .iter()
163 .any(|(from, path)| return *path == found.1 && raw.same_document(from, &found.0));
164 if !known {
165 uses.push(found);
166 grew = true;
167 }
168 }
169 }
170 if !grew {
171 break;
172 }
173 }
174 return uses;
175}
176
177impl Spec {
178 pub fn load(path: &Path) -> Result<Self> {
181 return Self::load_with(
182 path,
183 &crate::config::Generate::default(),
184 &crate::config::OutputOptions::default(),
185 );
186 }
187
188 pub fn load_for(path: &Path, server: bool, options: &crate::config::OutputOptions) -> Result<Self> {
192 let generate = crate::config::Generate {
193 std_http_server: server,
194 ..crate::config::Generate::default()
195 };
196 return Self::load_with(path, &generate, options);
197 }
198
199 pub fn load_with(
206 path: &Path,
207 generate: &crate::config::Generate,
208 options: &crate::config::OutputOptions,
209 ) -> Result<Self> {
210 let run = crate::coverage::Run {
211 server: generate.std_http_server,
212 operations: generate.std_http_server || generate.client,
213 referenced: false,
214 referenced_uses: Vec::new(),
215 filters: options.clone(),
216 };
217 let text = std::fs::read_to_string(path).map_err(|source| {
218 return Error::ReadSpec {
219 path: path.display().to_string(),
220 source,
221 };
222 })?;
223 let document = path.display().to_string();
224 let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
229 return Error::ParseSpec {
230 path: document.clone(),
231 source,
232 };
233 })?;
234 check_spec_version(&document, &value)?;
238 check_top_level_keys(&value)?;
239 let raw = RawDocuments::new(path);
240 let inspected = crate::coverage::check(&document, &value, &run, &raw)?;
241 let external_uses = settle_external_uses(inspected.external_uses, &run, &raw);
242 let mut value = value;
243 crate::coverage::wrap_described_refs(&mut value, &inspected.described_refs);
244 let inner: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
245 return Error::ParseSpec {
246 path: document.clone(),
247 source,
248 };
249 })?;
250 return Ok(Spec {
251 inner,
252 source: path.to_path_buf(),
253 docs: RefCell::new(HashMap::new()),
254 run,
255 external_uses,
256 raw,
257 });
258 }
259
260 pub fn from_parts(inner: OpenAPI, source: PathBuf) -> Self {
262 let raw = RawDocuments::new(&source);
263 return Spec {
264 inner,
265 source,
266 docs: RefCell::new(HashMap::new()),
267 run: crate::coverage::Run::default(),
268 external_uses: Vec::new(),
269 raw,
270 };
271 }
272
273 fn document_for(&self, file: &str) -> Result<Rc<OpenAPI>> {
276 let base = self.source.parent().unwrap_or_else(|| {
277 return Path::new(".");
278 });
279 let path = base.join(file);
280 if let Some(doc) = self.docs.borrow().get(&path) {
281 return Ok(Rc::clone(doc));
282 }
283 let text = std::fs::read_to_string(&path).map_err(|source| {
284 return Error::ReadRefFile {
285 file: file.to_owned(),
286 source,
287 };
288 })?;
289 let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
290 return Error::ParseRefFile {
291 file: file.to_owned(),
292 source,
293 };
294 })?;
295 check_spec_version(file, &value)?;
300 check_top_level_keys(&value)?;
301 let referenced_uses = self.raw.uses_of(&self.external_uses, file);
306 let run = crate::coverage::Run {
307 referenced: true,
308 referenced_uses,
309 ..self.run.clone()
310 };
311 let inspected = crate::coverage::check(file, &value, &run, &self.raw)?;
312 let mut value = value;
313 crate::coverage::wrap_described_refs(&mut value, &inspected.described_refs);
314 let parsed: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
315 return Error::ParseRefFile {
316 file: file.to_owned(),
317 source,
318 };
319 })?;
320 let doc = Rc::new(parsed);
321 self.docs.borrow_mut().insert(path, Rc::clone(&doc));
322 return Ok(doc);
323 }
324
325 pub fn source(&self) -> &Path {
327 return &self.source;
328 }
329
330 pub fn apply_filters(&mut self, opts: &crate::config::OutputOptions) {
333 crate::filter::apply(&mut self.inner, opts);
334 }
335
336 pub fn schemas(&self) -> &IndexMap<String, ReferenceOr<Schema>> {
338 let empty = EMPTY_SCHEMAS.get_or_init(IndexMap::new);
339 let schemas = self
340 .inner
341 .components
342 .as_ref()
343 .map(|c| {
344 return &c.schemas;
345 })
346 .unwrap_or(empty);
347 return schemas;
348 }
349
350 pub fn paths(&self) -> &openapiv3::Paths {
352 return &self.inner.paths;
353 }
354
355 pub fn servers(&self) -> &[openapiv3::Server] {
357 return &self.inner.servers;
358 }
359
360 pub fn global_security(&self) -> Option<&[openapiv3::SecurityRequirement]> {
363 return self.inner.security.as_deref();
364 }
365
366 pub fn security_schemes(&self) -> &IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>> {
369 let empty = EMPTY_SECURITY_SCHEMES.get_or_init(IndexMap::new);
370 let schemes = self
371 .inner
372 .components
373 .as_ref()
374 .map(|c| {
375 return &c.security_schemes;
376 })
377 .unwrap_or(empty);
378 return schemes;
379 }
380
381 pub fn resolve_response(&self, reference: &str) -> Result<Resolved<Response>> {
385 let mut current = reference.to_owned();
386 let mut origin: Option<String> = None;
387 for _ in 0..MAX_REF_DEPTH {
388 if let Some(file) = ref_file_part(¤t) {
389 origin = Some(file.to_owned());
390 }
391 let name = ref_component_name(¤t, "responses").ok_or_else(|| {
392 return Error::UnsupportedRef {
393 reference: current.clone(),
394 reason: "only `#/components/responses/<name>` references are supported".to_owned(),
395 };
396 })?;
397 let entry = self.component_response(origin.as_deref(), ¤t, name)?;
398 match entry {
399 ReferenceOr::Item(response) => {
400 return Ok(Resolved {
401 value: response,
402 origin,
403 });
404 }
405 ReferenceOr::Reference { reference } => {
406 current = reference;
407 }
408 }
409 }
410 return Err(Error::UnresolvedRef(reference.to_owned()));
411 }
412
413 pub fn resolve_parameter(&self, reference: &str) -> Result<Resolved<Parameter>> {
417 let mut current = reference.to_owned();
418 let mut origin: Option<String> = None;
419 for _ in 0..MAX_REF_DEPTH {
420 if let Some(file) = ref_file_part(¤t) {
421 origin = Some(file.to_owned());
422 }
423 let name = ref_component_name(¤t, "parameters").ok_or_else(|| {
424 return Error::UnsupportedRef {
425 reference: current.clone(),
426 reason: "only `#/components/parameters/<name>` references are supported".to_owned(),
427 };
428 })?;
429 let entry = self.component_parameter(origin.as_deref(), ¤t, name)?;
430 match entry {
431 ReferenceOr::Item(parameter) => {
432 return Ok(Resolved {
433 value: parameter,
434 origin,
435 });
436 }
437 ReferenceOr::Reference { reference } => {
438 current = reference;
439 }
440 }
441 }
442 return Err(Error::UnresolvedRef(reference.to_owned()));
443 }
444
445 pub fn resolve_request_body(&self, reference: &str) -> Result<Resolved<RequestBody>> {
449 let mut current = reference.to_owned();
450 let mut origin: Option<String> = None;
451 for _ in 0..MAX_REF_DEPTH {
452 if let Some(file) = ref_file_part(¤t) {
453 origin = Some(file.to_owned());
454 }
455 let name = ref_component_name(¤t, "requestBodies").ok_or_else(|| {
456 return Error::UnsupportedRef {
457 reference: current.clone(),
458 reason: "only `#/components/requestBodies/<name>` references are supported".to_owned(),
459 };
460 })?;
461 let entry = self.component_request_body(origin.as_deref(), ¤t, name)?;
462 match entry {
463 ReferenceOr::Item(body) => {
464 return Ok(Resolved { value: body, origin });
465 }
466 ReferenceOr::Reference { reference } => {
467 current = reference;
468 }
469 }
470 }
471 return Err(Error::UnresolvedRef(reference.to_owned()));
472 }
473
474 pub fn resolve(&self, reference: &str) -> Result<&Schema> {
477 let mut current = reference.to_owned();
478 for _ in 0..MAX_REF_DEPTH {
479 let name = ref_target_name(¤t).ok_or_else(|| {
480 return Error::UnsupportedRef {
481 reference: current.clone(),
482 reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
483 };
484 })?;
485 let entry = self
486 .schemas()
487 .get(name)
488 .ok_or_else(|| return Error::UnresolvedRef(current.clone()))?;
489 match entry {
490 ReferenceOr::Item(schema) => {
491 return Ok(schema);
492 }
493 ReferenceOr::Reference { reference } => {
494 current = reference.clone();
495 }
496 }
497 }
498 return Err(Error::UnresolvedRef(reference.to_owned()));
499 }
500
501 pub fn resolve_schema(&self, origin: Option<&str>, reference: &str) -> Result<Schema> {
510 let mut current = reference.to_owned();
511 for _ in 0..MAX_REF_DEPTH {
512 if ref_file_part(¤t).is_some() {
513 return Err(Error::UnsupportedRef {
514 reference: current.clone(),
515 reason: "cross-file schema `$ref`s are not supported here".to_owned(),
516 });
517 }
518 let name = ref_component_name(¤t, "schemas").ok_or_else(|| {
519 return Error::UnsupportedRef {
520 reference: current.clone(),
521 reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
522 };
523 })?;
524 let entry = self.component_schema(origin, ¤t, name)?;
525 match entry {
526 ReferenceOr::Item(schema) => {
527 return Ok(schema);
528 }
529 ReferenceOr::Reference { reference } => {
530 current = reference;
531 }
532 }
533 }
534 return Err(Error::UnresolvedRef(reference.to_owned()));
535 }
536
537 fn component_lookup<T>(
545 &self,
546 origin: Option<&str>,
547 reference: &str,
548 select: impl Fn(&OpenAPI) -> Option<ReferenceOr<T>>,
549 ) -> Result<ReferenceOr<T>> {
550 let entry = match origin {
551 None => select(&self.inner),
552 Some(file) => {
553 let doc = self.document_for(file)?;
554 select(doc.as_ref())
555 }
556 };
557 return entry.ok_or_else(|| return Error::UnresolvedRef(reference.to_owned()));
558 }
559
560 fn component_response(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Response>> {
562 return self.component_lookup(origin, reference, |doc| {
563 return doc.components.as_ref().and_then(|components| {
564 return components.responses.get(name).cloned();
565 });
566 });
567 }
568
569 fn component_parameter(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Parameter>> {
571 return self.component_lookup(origin, reference, |doc| {
572 return doc.components.as_ref().and_then(|components| {
573 return components.parameters.get(name).cloned();
574 });
575 });
576 }
577
578 fn component_request_body(
580 &self,
581 origin: Option<&str>,
582 reference: &str,
583 name: &str,
584 ) -> Result<ReferenceOr<RequestBody>> {
585 return self.component_lookup(origin, reference, |doc| {
586 return doc.components.as_ref().and_then(|components| {
587 return components.request_bodies.get(name).cloned();
588 });
589 });
590 }
591
592 fn component_schema(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Schema>> {
594 return self.component_lookup(origin, reference, |doc| {
595 return doc.components.as_ref().and_then(|components| {
596 return components.schemas.get(name).cloned();
597 });
598 });
599 }
600
601 pub fn external_schema_name(&self, file: &str, name: &str, reference: &str) -> Result<String> {
622 let entry = self.component_schema(Some(file), reference, name)?;
623 let chosen = external_name_of(&entry, name)?;
624 let doc = self.document_for(file)?;
625 let schemas = doc.components.as_ref().map(|components| return &components.schemas);
626 let ident = crate::naming::to_ident(&chosen, crate::naming::Case::Pascal);
627 if direction_split_schemas(&doc).contains(name) {
628 let shape = ident.logical();
629 return Err(Error::UnsupportedRef {
630 reference: reference.to_owned(),
631 reason: format!(
632 "`{name}` in `{file}` marks a property `readOnly` or `writeOnly`, so the run that \
633 generates that file emits it as `{shape}{REQUEST_SUFFIX}` and `{shape}{RESPONSE_SUFFIX}`. \
634 This run cannot tell which of the two an `import-mapping` reference means. Declare \
635 the schema in this document instead, or drop the mark"
636 ),
637 });
638 }
639 for (other, other_entry) in schemas.into_iter().flatten() {
640 if other == name {
641 continue;
642 }
643 let taken = external_name_of(other_entry, other)?;
644 if crate::naming::to_ident(&taken, crate::naming::Case::Pascal).logical() == ident.logical() {
645 return Err(Error::UnsupportedRef {
646 reference: reference.to_owned(),
647 reason: format!(
648 "`{file}` gives `{name}` and `{other}` the one Rust name `{}`",
649 ident.logical()
650 ),
651 });
652 }
653 }
654 return Ok(chosen);
655 }
656}
657
658fn direction_split_schemas(doc: &OpenAPI) -> std::collections::BTreeSet<String> {
671 let mut marked = std::collections::BTreeSet::new();
672 let Some(components) = doc.components.as_ref() else {
673 return marked;
674 };
675
676 let directional: std::collections::BTreeSet<String> = components
681 .schemas
682 .keys()
683 .filter(|name| {
684 return resolved_component(&components.schemas, name)
685 .is_some_and(|schema| return schema.schema_data.read_only || schema.schema_data.write_only);
686 })
687 .cloned()
688 .collect();
689
690 let mut referrers: HashMap<String, Vec<String>> = HashMap::new();
691 for (name, entry) in &components.schemas {
692 match entry {
693 ReferenceOr::Item(schema) => {
694 if schema_marks_a_direction(schema, &directional) {
695 marked.insert(name.clone());
696 }
697 let mut targets = Vec::new();
698 schema_local_refs(schema, &mut targets);
699 for target in targets {
700 referrers.entry(target).or_default().push(name.clone());
701 }
702 }
703 ReferenceOr::Reference { reference } => {
706 if let Some(target) = ref_target_name(reference) {
707 referrers.entry(target.to_owned()).or_default().push(name.clone());
708 }
709 }
710 }
711 }
712
713 let mut stack: Vec<String> = marked.iter().cloned().collect();
714 while let Some(name) = stack.pop() {
715 let Some(parents) = referrers.get(&name) else {
716 continue;
717 };
718 for parent in parents {
719 if marked.insert(parent.clone()) {
720 stack.push(parent.clone());
721 }
722 }
723 }
724 return marked;
725}
726
727fn schema_marks_a_direction(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
733 let mut found = declares_a_directional_property(schema, directional);
734 walk_inline_schemas(schema, &mut |inner| {
735 found = found || declares_a_directional_property(inner, directional);
736 });
737 return found;
738}
739
740fn declares_a_directional_property(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
742 let names_a_marked_schema = |reference: &str| {
743 return ref_file_part(reference).is_none()
744 && ref_component_name(reference, "schemas").is_some_and(|name| return directional.contains(name));
745 };
746 return object_properties(schema).iter().any(|entry| {
747 return match entry {
748 ReferenceOr::Item(inner) => {
751 inner.schema_data.read_only
752 || inner.schema_data.write_only
753 || single_ref_member(inner).is_some_and(names_a_marked_schema)
754 }
755 ReferenceOr::Reference { reference } => names_a_marked_schema(reference),
756 };
757 });
758}
759
760fn resolved_component<'a>(schemas: &'a IndexMap<String, ReferenceOr<Schema>>, name: &str) -> Option<&'a Schema> {
766 let mut current = name;
767 for _ in 0..MAX_REF_DEPTH {
768 match schemas.get(current)? {
769 ReferenceOr::Item(schema) => return Some(schema),
770 ReferenceOr::Reference { reference } => {
771 if ref_file_part(reference).is_some() {
772 return None;
773 }
774 current = ref_component_name(reference, "schemas")?;
775 }
776 }
777 }
778 return None;
779}
780
781pub(crate) fn single_ref_member(schema: &Schema) -> Option<&str> {
786 let openapiv3::SchemaKind::AllOf { all_of } = &schema.schema_kind else {
787 return None;
788 };
789 let [ReferenceOr::Reference { reference }] = all_of.as_slice() else {
790 return None;
791 };
792 return Some(reference);
793}
794
795fn object_properties(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
797 let unbox = |entry: &ReferenceOr<Box<Schema>>| {
798 return match entry {
799 ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
800 ReferenceOr::Reference { reference } => ReferenceOr::Reference {
801 reference: reference.clone(),
802 },
803 };
804 };
805 return match &schema.schema_kind {
806 openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
807 return object.properties.values().map(&unbox).collect();
808 }
809 openapiv3::SchemaKind::Any(any) => return any.properties.values().map(&unbox).collect(),
810 openapiv3::SchemaKind::Type(_)
811 | openapiv3::SchemaKind::OneOf { .. }
812 | openapiv3::SchemaKind::AllOf { .. }
813 | openapiv3::SchemaKind::AnyOf { .. }
814 | openapiv3::SchemaKind::Not { .. } => Vec::new(),
815 };
816}
817
818fn schema_local_refs(schema: &Schema, out: &mut Vec<String>) {
820 let mut collect = |entry: &ReferenceOr<Schema>| {
821 if let ReferenceOr::Reference { reference } = entry
822 && ref_file_part(reference).is_none()
823 && let Some(name) = ref_component_name(reference, "schemas")
824 {
825 out.push(name.to_owned());
826 }
827 };
828 for entry in inline_members(schema) {
829 collect(&entry);
830 }
831 walk_inline_schemas(schema, &mut |inner| {
832 for entry in inline_members(inner) {
833 collect(&entry);
834 }
835 });
836}
837
838fn walk_inline_schemas(schema: &Schema, visit: &mut impl FnMut(&Schema)) {
840 for entry in inline_members(schema) {
841 if let ReferenceOr::Item(inner) = entry {
842 visit(&inner);
843 walk_inline_schemas(&inner, visit);
844 }
845 }
846}
847
848fn inline_members(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
851 let unbox = |entry: &ReferenceOr<Box<Schema>>| {
852 return match entry {
853 ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
854 ReferenceOr::Reference { reference } => ReferenceOr::Reference {
855 reference: reference.clone(),
856 },
857 };
858 };
859 let additional = |entry: &Option<openapiv3::AdditionalProperties>| {
860 return match entry {
861 Some(openapiv3::AdditionalProperties::Schema(inner)) => vec![(**inner).clone()],
862 Some(openapiv3::AdditionalProperties::Any(_)) | None => Vec::new(),
863 };
864 };
865 let mut members = Vec::new();
866 match &schema.schema_kind {
867 openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
868 members.extend(object.properties.values().map(&unbox));
869 members.extend(additional(&object.additional_properties));
870 }
871 openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
872 members.extend(array.items.as_ref().map(unbox));
873 }
874 openapiv3::SchemaKind::Type(_) => {}
875 openapiv3::SchemaKind::OneOf { one_of } => members.extend(one_of.iter().cloned()),
876 openapiv3::SchemaKind::AllOf { all_of } => members.extend(all_of.iter().cloned()),
877 openapiv3::SchemaKind::AnyOf { any_of } => members.extend(any_of.iter().cloned()),
878 openapiv3::SchemaKind::Not { not } => members.push((**not).clone()),
879 openapiv3::SchemaKind::Any(any) => {
880 members.extend(any.properties.values().map(&unbox));
881 members.extend(additional(&any.additional_properties));
882 members.extend(any.items.as_ref().map(unbox));
883 members.extend(any.one_of.iter().cloned());
884 members.extend(any.all_of.iter().cloned());
885 members.extend(any.any_of.iter().cloned());
886 members.extend(any.not.as_ref().map(|not| return (**not).clone()));
887 }
888 }
889 return members;
890}
891
892fn external_name_of(entry: &ReferenceOr<Schema>, name: &str) -> Result<String> {
895 let renamed = match entry {
896 ReferenceOr::Item(schema) => {
897 crate::lower::extension::str_value(&schema.schema_data.extensions, crate::naming::X_RUST_NAME, name)?
898 }
899 ReferenceOr::Reference { .. } => None,
900 };
901 return Ok(renamed.unwrap_or(name).to_owned());
902}
903
904fn check_spec_version(document: &str, value: &serde_yaml::Value) -> Result<()> {
920 let Some(version) = value.get("openapi").and_then(serde_yaml::Value::as_str) else {
921 return Ok(());
922 };
923 let version = version.trim();
924 let supported = SUPPORTED_SPEC_VERSIONS.iter().any(|minor| {
927 return version == *minor || version.starts_with(&format!("{minor}."));
928 });
929 if supported {
930 return Ok(());
931 }
932 let reads = SUPPORTED_SPEC_VERSIONS
933 .iter()
934 .map(|minor| {
935 return format!("{minor}.x");
936 })
937 .collect::<Vec<_>>()
938 .join(", ");
939 return Err(Error::UnsupportedSpecVersion {
940 document: document.to_owned(),
941 version: version.to_owned(),
942 hint: format!("The generator reads OpenAPI {reads} only."),
943 });
944}
945
946fn check_top_level_keys(value: &serde_yaml::Value) -> Result<()> {
954 let Some(mapping) = value.as_mapping() else {
955 return Ok(());
956 };
957 for key in UNSUPPORTED_TOP_LEVEL_KEYS {
958 if mapping.contains_key(serde_yaml::Value::String(key.to_owned())) {
959 return Err(Error::UnsupportedSpecKey {
960 key: key.to_owned(),
961 reason: "declares operations the generator cannot emit".to_owned(),
962 hint: format!(
963 "Remove `{key}:`, or move its operations under `paths:`. Silently ignoring it emits no handler for any operation it holds."
964 ),
965 });
966 }
967 }
968 return Ok(());
969}
970
971pub fn ref_target_name(reference: &str) -> Option<&str> {
980 if ref_file_part(reference).is_some() {
981 return None;
982 }
983 return ref_component_name(reference, "schemas");
984}
985
986pub fn schema_ref_reason(reference: &str, site: &str) -> String {
994 if ref_file_part(reference).is_some() {
995 return format!("a cross-file ref does not resolve at {site}");
996 }
997 return format!("{site} must reference `#/components/schemas/<name>`");
998}
999
1000pub fn ref_component_name<'a>(reference: &'a str, kind: &str) -> Option<&'a str> {
1004 let fragment = reference.split('#').nth(1).unwrap_or(reference);
1005 let prefix = match kind {
1006 "schemas" => "/components/schemas/",
1007 "responses" => "/components/responses/",
1008 "parameters" => "/components/parameters/",
1009 "requestBodies" => "/components/requestBodies/",
1010 _ => return None,
1011 };
1012 let name = fragment.strip_prefix(prefix)?;
1013 if name.is_empty() || name.contains('/') {
1014 return None;
1015 }
1016 return Some(name);
1017}
1018
1019pub fn ref_file_part(reference: &str) -> Option<&str> {
1022 return match reference.split_once('#') {
1023 Some((file, _fragment)) if !file.is_empty() => Some(file),
1024 _ => None,
1025 };
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 struct TestDir {
1033 path: PathBuf,
1034 }
1035
1036 impl TestDir {
1037 fn new(test_name: &str) -> Self {
1038 let unique = format!(
1039 "oapi-codegen-loader-{test_name}-{}-{}",
1040 std::process::id(),
1041 std::time::SystemTime::now()
1042 .duration_since(std::time::UNIX_EPOCH)
1043 .expect("system clock should be after Unix epoch")
1044 .as_nanos(),
1045 );
1046 let path = std::env::temp_dir().join(unique);
1047 std::fs::create_dir_all(&path).expect("create test directory");
1048 return Self { path };
1049 }
1050
1051 fn write(&self, file: &str, contents: &str) -> PathBuf {
1052 let path = self.path.join(file);
1053 if let Some(parent) = path.parent() {
1054 std::fs::create_dir_all(parent).expect("create parent directory");
1055 }
1056 std::fs::write(&path, contents).expect("write test file");
1057 return path;
1058 }
1059 }
1060
1061 impl Drop for TestDir {
1062 fn drop(&mut self) {
1063 let _ = std::fs::remove_dir_all(&self.path);
1064 }
1065 }
1066
1067 fn parse_openapi(yaml: &str) -> OpenAPI {
1068 return serde_yaml::from_str(yaml).expect("parse OpenAPI document");
1069 }
1070
1071 fn assert_query_parameter_name(parameter: &Parameter, expected: &str) {
1072 match parameter {
1073 Parameter::Query { parameter_data, .. } => {
1074 assert_eq!(parameter_data.name, expected);
1075 }
1076 _ => panic!("expected a query parameter"),
1077 }
1078 }
1079
1080 fn minimal_doc() -> &'static str {
1081 return "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\n";
1082 }
1083
1084 fn shared_parameter_doc(name: &str) -> String {
1085 return format!(
1086 "openapi: 3.0.3\ninfo:\n title: shared\n version: '1'\npaths: {{}}\ncomponents:\n parameters:\n PageSize:\n name: {name}\n in: query\n schema:\n type: integer\n",
1087 );
1088 }
1089
1090 #[test]
1091 fn ref_target_name_is_same_document_schemas_only() {
1092 assert_eq!(ref_target_name("#/components/schemas/Foo"), Some("Foo"));
1093 assert_eq!(
1096 ref_target_name("schemas/common.yaml#/components/schemas/ErrorResponse"),
1097 None
1098 );
1099 assert_eq!(ref_target_name("#/components/responses/Bar"), None);
1100 }
1101
1102 #[test]
1105 fn a_schema_ref_reason_names_the_fault() {
1106 assert_eq!(
1107 schema_ref_reason("common.yaml#/components/schemas/X", "a property"),
1108 "a cross-file ref does not resolve at a property"
1109 );
1110 assert_eq!(
1111 schema_ref_reason("#/components/responses/Bar", "a property"),
1112 "a property must reference `#/components/schemas/<name>`"
1113 );
1114 }
1115
1116 #[test]
1117 fn extracts_component_responses_and_file_parts() {
1118 assert_eq!(
1119 ref_component_name("#/components/responses/Bar", "responses"),
1120 Some("Bar")
1121 );
1122 assert_eq!(ref_component_name("#/components/schemas/Foo", "responses"), None);
1123 assert_eq!(ref_file_part("#/components/schemas/Foo"), None);
1124 assert_eq!(
1125 ref_file_part("schemas/common.yaml#/components/schemas/ErrorResponse"),
1126 Some("schemas/common.yaml"),
1127 );
1128 }
1129
1130 #[test]
1131 fn ref_component_name_recognizes_parameters_and_request_bodies() {
1132 assert_eq!(
1133 ref_component_name("#/components/parameters/PageSize", "parameters"),
1134 Some("PageSize")
1135 );
1136 assert_eq!(
1137 ref_component_name("#/components/requestBodies/CreateWidget", "requestBodies"),
1138 Some("CreateWidget")
1139 );
1140 assert_eq!(ref_component_name("#/components/schemas/Foo", "parameters"), None);
1141 }
1142
1143 #[test]
1144 fn resolves_same_document_component_parameter() {
1145 let yaml = "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n parameters:\n PageSize:\n name: pageSize\n in: query\n schema:\n type: integer\n";
1146 let doc = parse_openapi(yaml);
1147 let spec = Spec::from_parts(doc, std::path::PathBuf::from("inline.yaml"));
1148 let param = spec
1149 .resolve_parameter("#/components/parameters/PageSize")
1150 .expect("resolve");
1151 assert!(param.origin.is_none());
1152 assert_query_parameter_name(¶m.value, "pageSize");
1153 }
1154
1155 #[test]
1156 fn errors_on_cross_file_ref_to_missing_file() {
1157 let source = std::env::temp_dir().join(format!(
1158 "oapi-codegen-loader-missing-{}-{}.yaml",
1159 std::process::id(),
1160 std::time::SystemTime::now()
1161 .duration_since(std::time::UNIX_EPOCH)
1162 .expect("system clock should be after Unix epoch")
1163 .as_nanos(),
1164 ));
1165 let spec = Spec::from_parts(parse_openapi(minimal_doc()), source);
1166 let result = spec.resolve_parameter("common.yaml#/components/parameters/PageSize");
1167 assert!(matches!(result, Err(Error::ReadRefFile { .. })));
1168 }
1169
1170 #[test]
1171 fn unresolved_component_error_preserves_the_full_reference() {
1172 let yaml = "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n parameters: {}\n";
1175 let spec = Spec::from_parts(parse_openapi(yaml), std::path::PathBuf::from("inline.yaml"));
1176 let result = spec.resolve_parameter("#/components/parameters/Missing");
1177 match result {
1178 Err(Error::UnresolvedRef(reference)) => {
1179 assert_eq!(reference, "#/components/parameters/Missing");
1180 }
1181 other => panic!("expected UnresolvedRef, got {other:?}"),
1182 }
1183
1184 let dir = TestDir::new("unresolved-cross-file");
1186 let main = dir.write("main.yaml", minimal_doc());
1187 dir.write("shared.yaml", minimal_doc());
1188 let spec = Spec::load(&main).expect("load main spec");
1189 let result = spec.resolve_parameter("shared.yaml#/components/parameters/Missing");
1190 match result {
1191 Err(Error::UnresolvedRef(reference)) => {
1192 assert_eq!(reference, "shared.yaml#/components/parameters/Missing");
1193 }
1194 other => panic!("expected UnresolvedRef, got {other:?}"),
1195 }
1196 }
1197
1198 #[test]
1199 fn document_for_reads_sibling_and_caches() {
1200 let dir = TestDir::new("document-for-caches");
1201 let main = dir.write("main.yaml", minimal_doc());
1202 dir.write("shared.yaml", &shared_parameter_doc("pageSize"));
1203
1204 let spec = Spec::load(&main).expect("load main spec");
1205 let param = spec
1206 .resolve_parameter("shared.yaml#/components/parameters/PageSize")
1207 .expect("resolve cross-file parameter");
1208 assert_eq!(param.origin.as_deref(), Some("shared.yaml"));
1209 assert_query_parameter_name(¶m.value, "pageSize");
1210 assert_eq!(spec.docs.borrow().len(), 1);
1211
1212 let second = spec
1213 .resolve_parameter("shared.yaml#/components/parameters/PageSize")
1214 .expect("resolve cached cross-file parameter");
1215 assert_eq!(second.origin.as_deref(), Some("shared.yaml"));
1216 assert_query_parameter_name(&second.value, "pageSize");
1217 assert_eq!(spec.docs.borrow().len(), 1);
1218 }
1219
1220 #[test]
1223 fn external_uses_settle_across_documents_before_inspection() {
1224 let dir = TestDir::new("settled-uses");
1225 let main = dir.write(
1226 "main.yaml",
1227 "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths:\n /a:\n get:\n parameters:\n - $ref: 'a.yaml#/components/parameters/N'\n - $ref: 'b.yaml#/components/parameters/M'\n responses: {}\n",
1228 );
1229 dir.write(
1230 "a.yaml",
1231 "openapi: 3.0.3\ninfo:\n title: a\n version: '1'\npaths: {}\ncomponents:\n parameters:\n N:\n name: n\n in: query\n schema:\n type: string\n K:\n name: k\n in: query\n schema:\n type: string\n",
1232 );
1233 dir.write(
1234 "b.yaml",
1235 "openapi: 3.0.3\ninfo:\n title: b\n version: '1'\npaths: {}\ncomponents:\n parameters:\n M:\n $ref: 'a.yaml#/components/parameters/K'\n",
1236 );
1237 let generate = crate::config::Generate {
1238 std_http_server: true,
1239 ..crate::config::Generate::default()
1240 };
1241
1242 let spec = Spec::load_with(&main, &generate, &crate::config::OutputOptions::default()).expect("load main spec");
1243
1244 let mut uses = spec.external_uses.clone();
1245 uses.sort();
1246 assert_eq!(
1247 uses,
1248 vec![
1249 ("a.yaml".to_owned(), "/components/parameters/K".to_owned()),
1250 ("a.yaml".to_owned(), "/components/parameters/N".to_owned()),
1251 ("b.yaml".to_owned(), "/components/parameters/M".to_owned()),
1252 ]
1253 );
1254 }
1255
1256 #[test]
1260 fn uses_under_two_spellings_of_a_file_reach_its_inspection() {
1261 for first in ["shared.yaml", "./shared.yaml"] {
1262 let second = if first == "shared.yaml" {
1263 "./shared.yaml"
1264 } else {
1265 "shared.yaml"
1266 };
1267 let dir = TestDir::new("spellings");
1268 let main = dir.write(
1269 "main.yaml",
1270 &format!(
1271 "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths:\n /a:\n get:\n parameters:\n - $ref: '{first}#/components/parameters/N'\n - $ref: '{second}#/components/parameters/M'\n responses: {{}}\n"
1272 ),
1273 );
1274 dir.write(
1275 "shared.yaml",
1276 "openapi: 3.0.3\ninfo:\n title: s\n version: '1'\npaths: {}\ncomponents:\n parameters:\n N:\n name: n\n in: query\n schema:\n type: string\n M:\n name: m\n in: query\n schema:\n type: string\n",
1277 );
1278 let generate = crate::config::Generate {
1279 std_http_server: true,
1280 ..crate::config::Generate::default()
1281 };
1282
1283 let spec = Spec::load_with(&main, &generate, &crate::config::OutputOptions::default()).expect("load");
1284
1285 let mut uses = spec.raw.uses_of(&spec.external_uses, first);
1286 uses.sort();
1287 assert_eq!(
1288 uses,
1289 vec!["/components/parameters/M", "/components/parameters/N"],
1290 "{first}"
1291 );
1292 }
1293 }
1294
1295 #[test]
1296 fn cross_file_chain_across_two_files_resolves() {
1297 let dir = TestDir::new("cross-file-chain");
1298 let main = dir.write("main.yaml", minimal_doc());
1299 dir.write(
1300 "a.yaml",
1301 "openapi: 3.0.3\ninfo:\n title: a\n version: '1'\npaths: {}\ncomponents:\n parameters:\n X:\n $ref: \"b.yaml#/components/parameters/Y\"\n",
1302 );
1303 dir.write(
1304 "b.yaml",
1305 "openapi: 3.0.3\ninfo:\n title: b\n version: '1'\npaths: {}\ncomponents:\n parameters:\n Y:\n name: cursor\n in: query\n schema:\n type: string\n",
1306 );
1307
1308 let spec = Spec::load(&main).expect("load main spec");
1309 let param = spec
1310 .resolve_parameter("a.yaml#/components/parameters/X")
1311 .expect("resolve cross-file chain");
1312 assert_eq!(param.origin.as_deref(), Some("b.yaml"));
1313 assert_query_parameter_name(¶m.value, "cursor");
1314 }
1315
1316 #[test]
1317 fn cross_file_cycle_terminates() {
1318 let dir = TestDir::new("cross-file-cycle");
1319 let main = dir.write("main.yaml", minimal_doc());
1320 dir.write(
1321 "a.yaml",
1322 "openapi: 3.0.3\ninfo:\n title: a\n version: '1'\npaths: {}\ncomponents:\n parameters:\n X:\n $ref: \"b.yaml#/components/parameters/Y\"\n",
1323 );
1324 dir.write(
1325 "b.yaml",
1326 "openapi: 3.0.3\ninfo:\n title: b\n version: '1'\npaths: {}\ncomponents:\n parameters:\n Y:\n $ref: \"a.yaml#/components/parameters/X\"\n",
1327 );
1328
1329 let spec = Spec::load(&main).expect("load main spec");
1330 let result = spec.resolve_parameter("a.yaml#/components/parameters/X");
1331 assert!(matches!(result, Err(Error::UnresolvedRef(_))));
1332 }
1333
1334 #[test]
1335 fn resolve_schema_against_origin() {
1336 let dir = TestDir::new("resolve-schema-origin");
1337 let main = dir.write("main.yaml", minimal_doc());
1338 dir.write(
1339 "shared.yaml",
1340 "openapi: 3.0.3\ninfo:\n title: shared\n version: '1'\npaths: {}\ncomponents:\n schemas:\n PageInfo:\n type: integer\n format: int32\n",
1341 );
1342
1343 let spec = Spec::load(&main).expect("load main spec");
1344 let schema = spec
1345 .resolve_schema(Some("shared.yaml"), "#/components/schemas/PageInfo")
1346 .expect("resolve schema from origin");
1347 assert!(matches!(
1348 schema.schema_kind,
1349 openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_))
1350 ));
1351 }
1352
1353 #[test]
1359 fn a_schema_level_mark_splits_only_the_referrers() {
1360 let doc: OpenAPI = serde_yaml::from_str(
1361 "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Marked:\n type: string\n readOnly: true\n Holder:\n type: object\n properties:\n a:\n $ref: '#/components/schemas/Marked'\n Bystander:\n type: object\n properties:\n b:\n type: string\n",
1362 )
1363 .expect("parse doc");
1364
1365 let split = direction_split_schemas(&doc);
1366 assert!(split.contains("Holder"), "a property that names a marked schema splits");
1367 assert!(!split.contains("Marked"), "a marked primitive keeps its one name");
1368 assert!(!split.contains("Bystander"), "an unrelated schema keeps its one name");
1369 }
1370
1371 #[test]
1377 fn an_alias_to_a_split_model_splits_as_well() {
1378 let doc: OpenAPI = serde_yaml::from_str(
1379 "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Marked:\n type: object\n properties:\n id:\n type: string\n readOnly: true\n Alias:\n $ref: '#/components/schemas/Marked'\n Plain:\n type: object\n properties:\n a:\n type: string\n PlainAlias:\n $ref: '#/components/schemas/Plain'\n",
1380 )
1381 .expect("parse doc");
1382
1383 let split = direction_split_schemas(&doc);
1384 assert!(split.contains("Marked"), "the model that declares the mark splits");
1385 assert!(split.contains("Alias"), "an alias to a split model splits");
1386 assert!(!split.contains("Plain"), "an unmarked model keeps its one name");
1387 assert!(
1388 !split.contains("PlainAlias"),
1389 "an alias to an unmarked model keeps its one name"
1390 );
1391 }
1392
1393 #[test]
1397 fn an_alias_chain_to_a_marked_schema_splits_the_referrer() {
1398 let doc: OpenAPI = serde_yaml::from_str(
1399 "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Marked:\n type: string\n readOnly: true\n Alias:\n $ref: '#/components/schemas/Marked'\n Twice:\n $ref: '#/components/schemas/Alias'\n Holder:\n type: object\n properties:\n a:\n $ref: '#/components/schemas/Alias'\n Wrapped:\n type: object\n properties:\n a:\n allOf:\n - $ref: '#/components/schemas/Twice'\n",
1400 )
1401 .expect("parse doc");
1402
1403 let split = direction_split_schemas(&doc);
1404 assert!(split.contains("Holder"), "a name of an alias to a marked schema splits");
1405 assert!(split.contains("Wrapped"), "a wrapped name of a two-step alias splits");
1406 assert!(
1407 !split.contains("Alias") && !split.contains("Twice"),
1408 "an alias to a primitive keeps its one name"
1409 );
1410 }
1411
1412 #[test]
1416 fn an_alias_chain_at_the_depth_limit_splits_and_one_link_longer_does_not() {
1417 let chain = |links: usize, reversed: bool| {
1418 let mut schemas: Vec<String> = vec![" Marked:\n type: string\n readOnly: true\n".to_owned()];
1419 for index in 1..=links {
1420 let target = if index == links {
1421 "Marked".to_owned()
1422 } else {
1423 format!("Alias{}", index + 1)
1424 };
1425 schemas.push(format!(
1426 " Alias{index}:\n $ref: '#/components/schemas/{target}'\n"
1427 ));
1428 }
1429 if reversed {
1430 schemas.reverse();
1431 }
1432 return format!(
1433 "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {{}}\ncomponents:\n schemas:\n{} Holder:\n type: object\n properties:\n a:\n $ref: '#/components/schemas/Alias1'\n",
1434 schemas.concat()
1435 );
1436 };
1437 for (links, splits) in [(MAX_REF_DEPTH - 1, true), (MAX_REF_DEPTH, false)] {
1438 for reversed in [false, true] {
1439 let doc: OpenAPI = serde_yaml::from_str(&chain(links, reversed)).expect("parse doc");
1440 let split = direction_split_schemas(&doc);
1441 assert_eq!(split.contains("Holder"), splits, "{links} links, reversed: {reversed}");
1442 }
1443 }
1444 }
1445
1446 #[test]
1449 fn a_wrapped_reference_to_a_marked_schema_splits_the_referrer() {
1450 let doc: OpenAPI = serde_yaml::from_str(
1451 "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Marked:\n type: string\n readOnly: true\n Plain:\n type: string\n Holder:\n type: object\n properties:\n a:\n allOf:\n - $ref: '#/components/schemas/Marked'\n description: wrapped\n Bystander:\n type: object\n properties:\n b:\n allOf:\n - $ref: '#/components/schemas/Plain'\n",
1452 )
1453 .expect("parse doc");
1454
1455 let split = direction_split_schemas(&doc);
1456 assert!(split.contains("Holder"), "a wrapped name of a marked schema splits");
1457 assert!(
1458 !split.contains("Bystander"),
1459 "a wrapped name of a plain schema does not"
1460 );
1461 }
1462
1463 #[test]
1466 fn a_property_mark_splits_the_declaring_model_and_its_referrers() {
1467 let doc: OpenAPI = serde_yaml::from_str(
1468 "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Leaf:\n type: object\n properties:\n id:\n type: string\n readOnly: true\n Parent:\n type: object\n properties:\n leaf:\n $ref: '#/components/schemas/Leaf'\n",
1469 )
1470 .expect("parse doc");
1471
1472 let split = direction_split_schemas(&doc);
1473 assert!(split.contains("Leaf"), "the model that declares the mark splits");
1474 assert!(split.contains("Parent"), "a model that reaches the mark splits");
1475 }
1476}