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
22const 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}
71
72impl Spec {
73 pub fn load(path: &Path) -> Result<Self> {
76 return Self::load_for(path, false, &crate::config::OutputOptions::default());
77 }
78
79 pub fn load_for(path: &Path, server: bool, options: &crate::config::OutputOptions) -> Result<Self> {
86 let run = crate::coverage::Run {
87 server,
88 filters: options.clone(),
89 };
90 let text = std::fs::read_to_string(path).map_err(|source| {
91 return Error::ReadSpec {
92 path: path.display().to_string(),
93 source,
94 };
95 })?;
96 let document = path.display().to_string();
97 let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
102 return Error::ParseSpec {
103 path: document.clone(),
104 source,
105 };
106 })?;
107 check_spec_version(&document, &value)?;
111 check_top_level_keys(&value)?;
112 let described = crate::coverage::check(&document, &value, &run)?;
113 let mut value = value;
114 crate::coverage::wrap_described_refs(&mut value, &described);
115 let inner: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
116 return Error::ParseSpec {
117 path: document.clone(),
118 source,
119 };
120 })?;
121 return Ok(Spec {
122 inner,
123 source: path.to_path_buf(),
124 docs: RefCell::new(HashMap::new()),
125 run,
126 });
127 }
128
129 pub fn from_parts(inner: OpenAPI, source: PathBuf) -> Self {
131 return Spec {
132 inner,
133 source,
134 docs: RefCell::new(HashMap::new()),
135 run: crate::coverage::Run::default(),
136 };
137 }
138
139 fn document_for(&self, file: &str) -> Result<Rc<OpenAPI>> {
142 let base = self.source.parent().unwrap_or_else(|| {
143 return Path::new(".");
144 });
145 let path = base.join(file);
146 if let Some(doc) = self.docs.borrow().get(&path) {
147 return Ok(Rc::clone(doc));
148 }
149 let text = std::fs::read_to_string(&path).map_err(|source| {
150 return Error::ReadRefFile {
151 file: file.to_owned(),
152 source,
153 };
154 })?;
155 let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
156 return Error::ParseRefFile {
157 file: file.to_owned(),
158 source,
159 };
160 })?;
161 check_spec_version(file, &value)?;
166 check_top_level_keys(&value)?;
167 let described = crate::coverage::check(file, &value, &self.run)?;
168 let mut value = value;
169 crate::coverage::wrap_described_refs(&mut value, &described);
170 let parsed: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
171 return Error::ParseRefFile {
172 file: file.to_owned(),
173 source,
174 };
175 })?;
176 let doc = Rc::new(parsed);
177 self.docs.borrow_mut().insert(path, Rc::clone(&doc));
178 return Ok(doc);
179 }
180
181 pub fn source(&self) -> &Path {
183 return &self.source;
184 }
185
186 pub fn apply_filters(&mut self, opts: &crate::config::OutputOptions) {
189 crate::filter::apply(&mut self.inner, opts);
190 }
191
192 pub fn schemas(&self) -> &IndexMap<String, ReferenceOr<Schema>> {
194 let empty = EMPTY_SCHEMAS.get_or_init(IndexMap::new);
195 let schemas = self
196 .inner
197 .components
198 .as_ref()
199 .map(|c| {
200 return &c.schemas;
201 })
202 .unwrap_or(empty);
203 return schemas;
204 }
205
206 pub fn paths(&self) -> &openapiv3::Paths {
208 return &self.inner.paths;
209 }
210
211 pub fn servers(&self) -> &[openapiv3::Server] {
213 return &self.inner.servers;
214 }
215
216 pub fn global_security(&self) -> Option<&[openapiv3::SecurityRequirement]> {
219 return self.inner.security.as_deref();
220 }
221
222 pub fn security_schemes(&self) -> &IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>> {
225 let empty = EMPTY_SECURITY_SCHEMES.get_or_init(IndexMap::new);
226 let schemes = self
227 .inner
228 .components
229 .as_ref()
230 .map(|c| {
231 return &c.security_schemes;
232 })
233 .unwrap_or(empty);
234 return schemes;
235 }
236
237 pub fn resolve_response(&self, reference: &str) -> Result<Resolved<Response>> {
241 let mut current = reference.to_owned();
242 let mut origin: Option<String> = None;
243 for _ in 0..MAX_REF_DEPTH {
244 if let Some(file) = ref_file_part(¤t) {
245 origin = Some(file.to_owned());
246 }
247 let name = ref_component_name(¤t, "responses").ok_or_else(|| {
248 return Error::UnsupportedRef {
249 reference: current.clone(),
250 reason: "only `#/components/responses/<name>` references are supported".to_owned(),
251 };
252 })?;
253 let entry = self.component_response(origin.as_deref(), ¤t, name)?;
254 match entry {
255 ReferenceOr::Item(response) => {
256 return Ok(Resolved {
257 value: response,
258 origin,
259 });
260 }
261 ReferenceOr::Reference { reference } => {
262 current = reference;
263 }
264 }
265 }
266 return Err(Error::UnresolvedRef(reference.to_owned()));
267 }
268
269 pub fn resolve_parameter(&self, reference: &str) -> Result<Resolved<Parameter>> {
273 let mut current = reference.to_owned();
274 let mut origin: Option<String> = None;
275 for _ in 0..MAX_REF_DEPTH {
276 if let Some(file) = ref_file_part(¤t) {
277 origin = Some(file.to_owned());
278 }
279 let name = ref_component_name(¤t, "parameters").ok_or_else(|| {
280 return Error::UnsupportedRef {
281 reference: current.clone(),
282 reason: "only `#/components/parameters/<name>` references are supported".to_owned(),
283 };
284 })?;
285 let entry = self.component_parameter(origin.as_deref(), ¤t, name)?;
286 match entry {
287 ReferenceOr::Item(parameter) => {
288 return Ok(Resolved {
289 value: parameter,
290 origin,
291 });
292 }
293 ReferenceOr::Reference { reference } => {
294 current = reference;
295 }
296 }
297 }
298 return Err(Error::UnresolvedRef(reference.to_owned()));
299 }
300
301 pub fn resolve_request_body(&self, reference: &str) -> Result<Resolved<RequestBody>> {
305 let mut current = reference.to_owned();
306 let mut origin: Option<String> = None;
307 for _ in 0..MAX_REF_DEPTH {
308 if let Some(file) = ref_file_part(¤t) {
309 origin = Some(file.to_owned());
310 }
311 let name = ref_component_name(¤t, "requestBodies").ok_or_else(|| {
312 return Error::UnsupportedRef {
313 reference: current.clone(),
314 reason: "only `#/components/requestBodies/<name>` references are supported".to_owned(),
315 };
316 })?;
317 let entry = self.component_request_body(origin.as_deref(), ¤t, name)?;
318 match entry {
319 ReferenceOr::Item(body) => {
320 return Ok(Resolved { value: body, origin });
321 }
322 ReferenceOr::Reference { reference } => {
323 current = reference;
324 }
325 }
326 }
327 return Err(Error::UnresolvedRef(reference.to_owned()));
328 }
329
330 pub fn resolve(&self, reference: &str) -> Result<&Schema> {
333 let mut current = reference.to_owned();
334 for _ in 0..MAX_REF_DEPTH {
335 let name = ref_target_name(¤t).ok_or_else(|| {
336 return Error::UnsupportedRef {
337 reference: current.clone(),
338 reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
339 };
340 })?;
341 let entry = self
342 .schemas()
343 .get(name)
344 .ok_or_else(|| return Error::UnresolvedRef(current.clone()))?;
345 match entry {
346 ReferenceOr::Item(schema) => {
347 return Ok(schema);
348 }
349 ReferenceOr::Reference { reference } => {
350 current = reference.clone();
351 }
352 }
353 }
354 return Err(Error::UnresolvedRef(reference.to_owned()));
355 }
356
357 pub fn resolve_schema(&self, origin: Option<&str>, reference: &str) -> Result<Schema> {
366 let mut current = reference.to_owned();
367 for _ in 0..MAX_REF_DEPTH {
368 if ref_file_part(¤t).is_some() {
369 return Err(Error::UnsupportedRef {
370 reference: current.clone(),
371 reason: "cross-file schema `$ref`s are not supported here".to_owned(),
372 });
373 }
374 let name = ref_component_name(¤t, "schemas").ok_or_else(|| {
375 return Error::UnsupportedRef {
376 reference: current.clone(),
377 reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
378 };
379 })?;
380 let entry = self.component_schema(origin, ¤t, name)?;
381 match entry {
382 ReferenceOr::Item(schema) => {
383 return Ok(schema);
384 }
385 ReferenceOr::Reference { reference } => {
386 current = reference;
387 }
388 }
389 }
390 return Err(Error::UnresolvedRef(reference.to_owned()));
391 }
392
393 fn component_lookup<T>(
401 &self,
402 origin: Option<&str>,
403 reference: &str,
404 select: impl Fn(&OpenAPI) -> Option<ReferenceOr<T>>,
405 ) -> Result<ReferenceOr<T>> {
406 let entry = match origin {
407 None => select(&self.inner),
408 Some(file) => {
409 let doc = self.document_for(file)?;
410 select(doc.as_ref())
411 }
412 };
413 return entry.ok_or_else(|| return Error::UnresolvedRef(reference.to_owned()));
414 }
415
416 fn component_response(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Response>> {
418 return self.component_lookup(origin, reference, |doc| {
419 return doc.components.as_ref().and_then(|components| {
420 return components.responses.get(name).cloned();
421 });
422 });
423 }
424
425 fn component_parameter(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Parameter>> {
427 return self.component_lookup(origin, reference, |doc| {
428 return doc.components.as_ref().and_then(|components| {
429 return components.parameters.get(name).cloned();
430 });
431 });
432 }
433
434 fn component_request_body(
436 &self,
437 origin: Option<&str>,
438 reference: &str,
439 name: &str,
440 ) -> Result<ReferenceOr<RequestBody>> {
441 return self.component_lookup(origin, reference, |doc| {
442 return doc.components.as_ref().and_then(|components| {
443 return components.request_bodies.get(name).cloned();
444 });
445 });
446 }
447
448 fn component_schema(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Schema>> {
450 return self.component_lookup(origin, reference, |doc| {
451 return doc.components.as_ref().and_then(|components| {
452 return components.schemas.get(name).cloned();
453 });
454 });
455 }
456
457 pub fn external_schema_name(&self, file: &str, name: &str, reference: &str) -> Result<String> {
478 let entry = self.component_schema(Some(file), reference, name)?;
479 let chosen = external_name_of(&entry, name)?;
480 let doc = self.document_for(file)?;
481 let schemas = doc.components.as_ref().map(|components| return &components.schemas);
482 let ident = crate::naming::to_ident(&chosen, crate::naming::Case::Pascal);
483 if direction_split_schemas(&doc).contains(name) {
484 let shape = ident.logical();
485 return Err(Error::UnsupportedRef {
486 reference: reference.to_owned(),
487 reason: format!(
488 "`{name}` in `{file}` marks a property `readOnly` or `writeOnly`, so the run that \
489 generates that file emits it as `{shape}{REQUEST_SUFFIX}` and `{shape}{RESPONSE_SUFFIX}`. \
490 This run cannot tell which of the two an `import-mapping` reference means. Declare \
491 the schema in this document instead, or drop the mark"
492 ),
493 });
494 }
495 for (other, other_entry) in schemas.into_iter().flatten() {
496 if other == name {
497 continue;
498 }
499 let taken = external_name_of(other_entry, other)?;
500 if crate::naming::to_ident(&taken, crate::naming::Case::Pascal).logical() == ident.logical() {
501 return Err(Error::UnsupportedRef {
502 reference: reference.to_owned(),
503 reason: format!(
504 "`{file}` gives `{name}` and `{other}` the one Rust name `{}`",
505 ident.logical()
506 ),
507 });
508 }
509 }
510 return Ok(chosen);
511 }
512}
513
514fn direction_split_schemas(doc: &OpenAPI) -> std::collections::BTreeSet<String> {
527 let mut marked = std::collections::BTreeSet::new();
528 let Some(components) = doc.components.as_ref() else {
529 return marked;
530 };
531
532 let directional: std::collections::BTreeSet<String> = components
537 .schemas
538 .keys()
539 .filter(|name| {
540 return resolved_component(&components.schemas, name)
541 .is_some_and(|schema| return schema.schema_data.read_only || schema.schema_data.write_only);
542 })
543 .cloned()
544 .collect();
545
546 let mut referrers: HashMap<String, Vec<String>> = HashMap::new();
547 for (name, entry) in &components.schemas {
548 match entry {
549 ReferenceOr::Item(schema) => {
550 if schema_marks_a_direction(schema, &directional) {
551 marked.insert(name.clone());
552 }
553 let mut targets = Vec::new();
554 schema_local_refs(schema, &mut targets);
555 for target in targets {
556 referrers.entry(target).or_default().push(name.clone());
557 }
558 }
559 ReferenceOr::Reference { reference } => {
562 if let Some(target) = ref_target_name(reference) {
563 referrers.entry(target.to_owned()).or_default().push(name.clone());
564 }
565 }
566 }
567 }
568
569 let mut stack: Vec<String> = marked.iter().cloned().collect();
570 while let Some(name) = stack.pop() {
571 let Some(parents) = referrers.get(&name) else {
572 continue;
573 };
574 for parent in parents {
575 if marked.insert(parent.clone()) {
576 stack.push(parent.clone());
577 }
578 }
579 }
580 return marked;
581}
582
583fn schema_marks_a_direction(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
589 let mut found = declares_a_directional_property(schema, directional);
590 walk_inline_schemas(schema, &mut |inner| {
591 found = found || declares_a_directional_property(inner, directional);
592 });
593 return found;
594}
595
596fn declares_a_directional_property(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
598 let names_a_marked_schema = |reference: &str| {
599 return ref_file_part(reference).is_none()
600 && ref_component_name(reference, "schemas").is_some_and(|name| return directional.contains(name));
601 };
602 return object_properties(schema).iter().any(|entry| {
603 return match entry {
604 ReferenceOr::Item(inner) => {
607 inner.schema_data.read_only
608 || inner.schema_data.write_only
609 || single_ref_member(inner).is_some_and(names_a_marked_schema)
610 }
611 ReferenceOr::Reference { reference } => names_a_marked_schema(reference),
612 };
613 });
614}
615
616fn resolved_component<'a>(schemas: &'a IndexMap<String, ReferenceOr<Schema>>, name: &str) -> Option<&'a Schema> {
622 let mut current = name;
623 for _ in 0..MAX_REF_DEPTH {
624 match schemas.get(current)? {
625 ReferenceOr::Item(schema) => return Some(schema),
626 ReferenceOr::Reference { reference } => {
627 if ref_file_part(reference).is_some() {
628 return None;
629 }
630 current = ref_component_name(reference, "schemas")?;
631 }
632 }
633 }
634 return None;
635}
636
637pub(crate) fn single_ref_member(schema: &Schema) -> Option<&str> {
642 let openapiv3::SchemaKind::AllOf { all_of } = &schema.schema_kind else {
643 return None;
644 };
645 let [ReferenceOr::Reference { reference }] = all_of.as_slice() else {
646 return None;
647 };
648 return Some(reference);
649}
650
651fn object_properties(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
653 let unbox = |entry: &ReferenceOr<Box<Schema>>| {
654 return match entry {
655 ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
656 ReferenceOr::Reference { reference } => ReferenceOr::Reference {
657 reference: reference.clone(),
658 },
659 };
660 };
661 return match &schema.schema_kind {
662 openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
663 return object.properties.values().map(&unbox).collect();
664 }
665 openapiv3::SchemaKind::Any(any) => return any.properties.values().map(&unbox).collect(),
666 openapiv3::SchemaKind::Type(_)
667 | openapiv3::SchemaKind::OneOf { .. }
668 | openapiv3::SchemaKind::AllOf { .. }
669 | openapiv3::SchemaKind::AnyOf { .. }
670 | openapiv3::SchemaKind::Not { .. } => Vec::new(),
671 };
672}
673
674fn schema_local_refs(schema: &Schema, out: &mut Vec<String>) {
676 let mut collect = |entry: &ReferenceOr<Schema>| {
677 if let ReferenceOr::Reference { reference } = entry
678 && ref_file_part(reference).is_none()
679 && let Some(name) = ref_component_name(reference, "schemas")
680 {
681 out.push(name.to_owned());
682 }
683 };
684 for entry in inline_members(schema) {
685 collect(&entry);
686 }
687 walk_inline_schemas(schema, &mut |inner| {
688 for entry in inline_members(inner) {
689 collect(&entry);
690 }
691 });
692}
693
694fn walk_inline_schemas(schema: &Schema, visit: &mut impl FnMut(&Schema)) {
696 for entry in inline_members(schema) {
697 if let ReferenceOr::Item(inner) = entry {
698 visit(&inner);
699 walk_inline_schemas(&inner, visit);
700 }
701 }
702}
703
704fn inline_members(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
707 let unbox = |entry: &ReferenceOr<Box<Schema>>| {
708 return match entry {
709 ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
710 ReferenceOr::Reference { reference } => ReferenceOr::Reference {
711 reference: reference.clone(),
712 },
713 };
714 };
715 let additional = |entry: &Option<openapiv3::AdditionalProperties>| {
716 return match entry {
717 Some(openapiv3::AdditionalProperties::Schema(inner)) => vec![(**inner).clone()],
718 Some(openapiv3::AdditionalProperties::Any(_)) | None => Vec::new(),
719 };
720 };
721 let mut members = Vec::new();
722 match &schema.schema_kind {
723 openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
724 members.extend(object.properties.values().map(&unbox));
725 members.extend(additional(&object.additional_properties));
726 }
727 openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
728 members.extend(array.items.as_ref().map(unbox));
729 }
730 openapiv3::SchemaKind::Type(_) => {}
731 openapiv3::SchemaKind::OneOf { one_of } => members.extend(one_of.iter().cloned()),
732 openapiv3::SchemaKind::AllOf { all_of } => members.extend(all_of.iter().cloned()),
733 openapiv3::SchemaKind::AnyOf { any_of } => members.extend(any_of.iter().cloned()),
734 openapiv3::SchemaKind::Not { not } => members.push((**not).clone()),
735 openapiv3::SchemaKind::Any(any) => {
736 members.extend(any.properties.values().map(&unbox));
737 members.extend(additional(&any.additional_properties));
738 members.extend(any.items.as_ref().map(unbox));
739 members.extend(any.one_of.iter().cloned());
740 members.extend(any.all_of.iter().cloned());
741 members.extend(any.any_of.iter().cloned());
742 members.extend(any.not.as_ref().map(|not| return (**not).clone()));
743 }
744 }
745 return members;
746}
747
748fn external_name_of(entry: &ReferenceOr<Schema>, name: &str) -> Result<String> {
751 let renamed = match entry {
752 ReferenceOr::Item(schema) => {
753 crate::lower::extension::str_value(&schema.schema_data.extensions, crate::naming::X_RUST_NAME, name)?
754 }
755 ReferenceOr::Reference { .. } => None,
756 };
757 return Ok(renamed.unwrap_or(name).to_owned());
758}
759
760fn check_spec_version(document: &str, value: &serde_yaml::Value) -> Result<()> {
776 let Some(version) = value.get("openapi").and_then(serde_yaml::Value::as_str) else {
777 return Ok(());
778 };
779 let version = version.trim();
780 let supported = SUPPORTED_SPEC_VERSIONS.iter().any(|minor| {
783 return version == *minor || version.starts_with(&format!("{minor}."));
784 });
785 if supported {
786 return Ok(());
787 }
788 let reads = SUPPORTED_SPEC_VERSIONS
789 .iter()
790 .map(|minor| {
791 return format!("{minor}.x");
792 })
793 .collect::<Vec<_>>()
794 .join(", ");
795 return Err(Error::UnsupportedSpecVersion {
796 document: document.to_owned(),
797 version: version.to_owned(),
798 hint: format!("The generator reads OpenAPI {reads} only."),
799 });
800}
801
802fn check_top_level_keys(value: &serde_yaml::Value) -> Result<()> {
810 let Some(mapping) = value.as_mapping() else {
811 return Ok(());
812 };
813 for key in UNSUPPORTED_TOP_LEVEL_KEYS {
814 if mapping.contains_key(serde_yaml::Value::String(key.to_owned())) {
815 return Err(Error::UnsupportedSpecKey {
816 key: key.to_owned(),
817 reason: "declares operations the generator cannot emit".to_owned(),
818 hint: format!(
819 "Remove `{key}:`, or move its operations under `paths:`. Silently ignoring it emits no handler for any operation it holds."
820 ),
821 });
822 }
823 }
824 return Ok(());
825}
826
827pub fn ref_target_name(reference: &str) -> Option<&str> {
836 if ref_file_part(reference).is_some() {
837 return None;
838 }
839 return ref_component_name(reference, "schemas");
840}
841
842pub fn schema_ref_reason(reference: &str, site: &str) -> String {
850 if ref_file_part(reference).is_some() {
851 return format!("a cross-file ref does not resolve at {site}");
852 }
853 return format!("{site} must reference `#/components/schemas/<name>`");
854}
855
856pub fn ref_component_name<'a>(reference: &'a str, kind: &str) -> Option<&'a str> {
860 let fragment = reference.split('#').nth(1).unwrap_or(reference);
861 let prefix = match kind {
862 "schemas" => "/components/schemas/",
863 "responses" => "/components/responses/",
864 "parameters" => "/components/parameters/",
865 "requestBodies" => "/components/requestBodies/",
866 _ => return None,
867 };
868 let name = fragment.strip_prefix(prefix)?;
869 if name.is_empty() || name.contains('/') {
870 return None;
871 }
872 return Some(name);
873}
874
875pub fn ref_file_part(reference: &str) -> Option<&str> {
878 return match reference.split_once('#') {
879 Some((file, _fragment)) if !file.is_empty() => Some(file),
880 _ => None,
881 };
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887
888 struct TestDir {
889 path: PathBuf,
890 }
891
892 impl TestDir {
893 fn new(test_name: &str) -> Self {
894 let unique = format!(
895 "oapi-codegen-loader-{test_name}-{}-{}",
896 std::process::id(),
897 std::time::SystemTime::now()
898 .duration_since(std::time::UNIX_EPOCH)
899 .expect("system clock should be after Unix epoch")
900 .as_nanos(),
901 );
902 let path = std::env::temp_dir().join(unique);
903 std::fs::create_dir_all(&path).expect("create test directory");
904 return Self { path };
905 }
906
907 fn write(&self, file: &str, contents: &str) -> PathBuf {
908 let path = self.path.join(file);
909 if let Some(parent) = path.parent() {
910 std::fs::create_dir_all(parent).expect("create parent directory");
911 }
912 std::fs::write(&path, contents).expect("write test file");
913 return path;
914 }
915 }
916
917 impl Drop for TestDir {
918 fn drop(&mut self) {
919 let _ = std::fs::remove_dir_all(&self.path);
920 }
921 }
922
923 fn parse_openapi(yaml: &str) -> OpenAPI {
924 return serde_yaml::from_str(yaml).expect("parse OpenAPI document");
925 }
926
927 fn assert_query_parameter_name(parameter: &Parameter, expected: &str) {
928 match parameter {
929 Parameter::Query { parameter_data, .. } => {
930 assert_eq!(parameter_data.name, expected);
931 }
932 _ => panic!("expected a query parameter"),
933 }
934 }
935
936 fn minimal_doc() -> &'static str {
937 return "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\n";
938 }
939
940 fn shared_parameter_doc(name: &str) -> String {
941 return format!(
942 "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",
943 );
944 }
945
946 #[test]
947 fn ref_target_name_is_same_document_schemas_only() {
948 assert_eq!(ref_target_name("#/components/schemas/Foo"), Some("Foo"));
949 assert_eq!(
952 ref_target_name("schemas/common.yaml#/components/schemas/ErrorResponse"),
953 None
954 );
955 assert_eq!(ref_target_name("#/components/responses/Bar"), None);
956 }
957
958 #[test]
961 fn a_schema_ref_reason_names_the_fault() {
962 assert_eq!(
963 schema_ref_reason("common.yaml#/components/schemas/X", "a property"),
964 "a cross-file ref does not resolve at a property"
965 );
966 assert_eq!(
967 schema_ref_reason("#/components/responses/Bar", "a property"),
968 "a property must reference `#/components/schemas/<name>`"
969 );
970 }
971
972 #[test]
973 fn extracts_component_responses_and_file_parts() {
974 assert_eq!(
975 ref_component_name("#/components/responses/Bar", "responses"),
976 Some("Bar")
977 );
978 assert_eq!(ref_component_name("#/components/schemas/Foo", "responses"), None);
979 assert_eq!(ref_file_part("#/components/schemas/Foo"), None);
980 assert_eq!(
981 ref_file_part("schemas/common.yaml#/components/schemas/ErrorResponse"),
982 Some("schemas/common.yaml"),
983 );
984 }
985
986 #[test]
987 fn ref_component_name_recognizes_parameters_and_request_bodies() {
988 assert_eq!(
989 ref_component_name("#/components/parameters/PageSize", "parameters"),
990 Some("PageSize")
991 );
992 assert_eq!(
993 ref_component_name("#/components/requestBodies/CreateWidget", "requestBodies"),
994 Some("CreateWidget")
995 );
996 assert_eq!(ref_component_name("#/components/schemas/Foo", "parameters"), None);
997 }
998
999 #[test]
1000 fn resolves_same_document_component_parameter() {
1001 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";
1002 let doc = parse_openapi(yaml);
1003 let spec = Spec::from_parts(doc, std::path::PathBuf::from("inline.yaml"));
1004 let param = spec
1005 .resolve_parameter("#/components/parameters/PageSize")
1006 .expect("resolve");
1007 assert!(param.origin.is_none());
1008 assert_query_parameter_name(¶m.value, "pageSize");
1009 }
1010
1011 #[test]
1012 fn errors_on_cross_file_ref_to_missing_file() {
1013 let source = std::env::temp_dir().join(format!(
1014 "oapi-codegen-loader-missing-{}-{}.yaml",
1015 std::process::id(),
1016 std::time::SystemTime::now()
1017 .duration_since(std::time::UNIX_EPOCH)
1018 .expect("system clock should be after Unix epoch")
1019 .as_nanos(),
1020 ));
1021 let spec = Spec::from_parts(parse_openapi(minimal_doc()), source);
1022 let result = spec.resolve_parameter("common.yaml#/components/parameters/PageSize");
1023 assert!(matches!(result, Err(Error::ReadRefFile { .. })));
1024 }
1025
1026 #[test]
1027 fn unresolved_component_error_preserves_the_full_reference() {
1028 let yaml = "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n parameters: {}\n";
1031 let spec = Spec::from_parts(parse_openapi(yaml), std::path::PathBuf::from("inline.yaml"));
1032 let result = spec.resolve_parameter("#/components/parameters/Missing");
1033 match result {
1034 Err(Error::UnresolvedRef(reference)) => {
1035 assert_eq!(reference, "#/components/parameters/Missing");
1036 }
1037 other => panic!("expected UnresolvedRef, got {other:?}"),
1038 }
1039
1040 let dir = TestDir::new("unresolved-cross-file");
1042 let main = dir.write("main.yaml", minimal_doc());
1043 dir.write("shared.yaml", minimal_doc());
1044 let spec = Spec::load(&main).expect("load main spec");
1045 let result = spec.resolve_parameter("shared.yaml#/components/parameters/Missing");
1046 match result {
1047 Err(Error::UnresolvedRef(reference)) => {
1048 assert_eq!(reference, "shared.yaml#/components/parameters/Missing");
1049 }
1050 other => panic!("expected UnresolvedRef, got {other:?}"),
1051 }
1052 }
1053
1054 #[test]
1055 fn document_for_reads_sibling_and_caches() {
1056 let dir = TestDir::new("document-for-caches");
1057 let main = dir.write("main.yaml", minimal_doc());
1058 dir.write("shared.yaml", &shared_parameter_doc("pageSize"));
1059
1060 let spec = Spec::load(&main).expect("load main spec");
1061 let param = spec
1062 .resolve_parameter("shared.yaml#/components/parameters/PageSize")
1063 .expect("resolve cross-file parameter");
1064 assert_eq!(param.origin.as_deref(), Some("shared.yaml"));
1065 assert_query_parameter_name(¶m.value, "pageSize");
1066 assert_eq!(spec.docs.borrow().len(), 1);
1067
1068 let second = spec
1069 .resolve_parameter("shared.yaml#/components/parameters/PageSize")
1070 .expect("resolve cached cross-file parameter");
1071 assert_eq!(second.origin.as_deref(), Some("shared.yaml"));
1072 assert_query_parameter_name(&second.value, "pageSize");
1073 assert_eq!(spec.docs.borrow().len(), 1);
1074 }
1075
1076 #[test]
1077 fn cross_file_chain_across_two_files_resolves() {
1078 let dir = TestDir::new("cross-file-chain");
1079 let main = dir.write("main.yaml", minimal_doc());
1080 dir.write(
1081 "a.yaml",
1082 "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",
1083 );
1084 dir.write(
1085 "b.yaml",
1086 "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",
1087 );
1088
1089 let spec = Spec::load(&main).expect("load main spec");
1090 let param = spec
1091 .resolve_parameter("a.yaml#/components/parameters/X")
1092 .expect("resolve cross-file chain");
1093 assert_eq!(param.origin.as_deref(), Some("b.yaml"));
1094 assert_query_parameter_name(¶m.value, "cursor");
1095 }
1096
1097 #[test]
1098 fn cross_file_cycle_terminates() {
1099 let dir = TestDir::new("cross-file-cycle");
1100 let main = dir.write("main.yaml", minimal_doc());
1101 dir.write(
1102 "a.yaml",
1103 "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",
1104 );
1105 dir.write(
1106 "b.yaml",
1107 "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",
1108 );
1109
1110 let spec = Spec::load(&main).expect("load main spec");
1111 let result = spec.resolve_parameter("a.yaml#/components/parameters/X");
1112 assert!(matches!(result, Err(Error::UnresolvedRef(_))));
1113 }
1114
1115 #[test]
1116 fn resolve_schema_against_origin() {
1117 let dir = TestDir::new("resolve-schema-origin");
1118 let main = dir.write("main.yaml", minimal_doc());
1119 dir.write(
1120 "shared.yaml",
1121 "openapi: 3.0.3\ninfo:\n title: shared\n version: '1'\npaths: {}\ncomponents:\n schemas:\n PageInfo:\n type: integer\n format: int32\n",
1122 );
1123
1124 let spec = Spec::load(&main).expect("load main spec");
1125 let schema = spec
1126 .resolve_schema(Some("shared.yaml"), "#/components/schemas/PageInfo")
1127 .expect("resolve schema from origin");
1128 assert!(matches!(
1129 schema.schema_kind,
1130 openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_))
1131 ));
1132 }
1133
1134 #[test]
1140 fn a_schema_level_mark_splits_only_the_referrers() {
1141 let doc: OpenAPI = serde_yaml::from_str(
1142 "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",
1143 )
1144 .expect("parse doc");
1145
1146 let split = direction_split_schemas(&doc);
1147 assert!(split.contains("Holder"), "a property that names a marked schema splits");
1148 assert!(!split.contains("Marked"), "a marked primitive keeps its one name");
1149 assert!(!split.contains("Bystander"), "an unrelated schema keeps its one name");
1150 }
1151
1152 #[test]
1158 fn an_alias_to_a_split_model_splits_as_well() {
1159 let doc: OpenAPI = serde_yaml::from_str(
1160 "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",
1161 )
1162 .expect("parse doc");
1163
1164 let split = direction_split_schemas(&doc);
1165 assert!(split.contains("Marked"), "the model that declares the mark splits");
1166 assert!(split.contains("Alias"), "an alias to a split model splits");
1167 assert!(!split.contains("Plain"), "an unmarked model keeps its one name");
1168 assert!(
1169 !split.contains("PlainAlias"),
1170 "an alias to an unmarked model keeps its one name"
1171 );
1172 }
1173
1174 #[test]
1178 fn an_alias_chain_to_a_marked_schema_splits_the_referrer() {
1179 let doc: OpenAPI = serde_yaml::from_str(
1180 "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",
1181 )
1182 .expect("parse doc");
1183
1184 let split = direction_split_schemas(&doc);
1185 assert!(split.contains("Holder"), "a name of an alias to a marked schema splits");
1186 assert!(split.contains("Wrapped"), "a wrapped name of a two-step alias splits");
1187 assert!(
1188 !split.contains("Alias") && !split.contains("Twice"),
1189 "an alias to a primitive keeps its one name"
1190 );
1191 }
1192
1193 #[test]
1197 fn an_alias_chain_at_the_depth_limit_splits_and_one_link_longer_does_not() {
1198 let chain = |links: usize, reversed: bool| {
1199 let mut schemas: Vec<String> = vec![" Marked:\n type: string\n readOnly: true\n".to_owned()];
1200 for index in 1..=links {
1201 let target = if index == links {
1202 "Marked".to_owned()
1203 } else {
1204 format!("Alias{}", index + 1)
1205 };
1206 schemas.push(format!(
1207 " Alias{index}:\n $ref: '#/components/schemas/{target}'\n"
1208 ));
1209 }
1210 if reversed {
1211 schemas.reverse();
1212 }
1213 return format!(
1214 "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",
1215 schemas.concat()
1216 );
1217 };
1218 for (links, splits) in [(MAX_REF_DEPTH - 1, true), (MAX_REF_DEPTH, false)] {
1219 for reversed in [false, true] {
1220 let doc: OpenAPI = serde_yaml::from_str(&chain(links, reversed)).expect("parse doc");
1221 let split = direction_split_schemas(&doc);
1222 assert_eq!(split.contains("Holder"), splits, "{links} links, reversed: {reversed}");
1223 }
1224 }
1225 }
1226
1227 #[test]
1230 fn a_wrapped_reference_to_a_marked_schema_splits_the_referrer() {
1231 let doc: OpenAPI = serde_yaml::from_str(
1232 "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",
1233 )
1234 .expect("parse doc");
1235
1236 let split = direction_split_schemas(&doc);
1237 assert!(split.contains("Holder"), "a wrapped name of a marked schema splits");
1238 assert!(
1239 !split.contains("Bystander"),
1240 "a wrapped name of a plain schema does not"
1241 );
1242 }
1243
1244 #[test]
1247 fn a_property_mark_splits_the_declaring_model_and_its_referrers() {
1248 let doc: OpenAPI = serde_yaml::from_str(
1249 "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",
1250 )
1251 .expect("parse doc");
1252
1253 let split = direction_split_schemas(&doc);
1254 assert!(split.contains("Leaf"), "the model that declares the mark splits");
1255 assert!(split.contains("Parent"), "a model that reaches the mark splits");
1256 }
1257}