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}
67
68impl Spec {
69 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 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 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 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 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 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 pub fn source(&self) -> &Path {
156 return &self.source;
157 }
158
159 pub fn apply_filters(&mut self, opts: &crate::config::OutputOptions) {
162 crate::filter::apply(&mut self.inner, opts);
163 }
164
165 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 pub fn paths(&self) -> &openapiv3::Paths {
181 return &self.inner.paths;
182 }
183
184 pub fn servers(&self) -> &[openapiv3::Server] {
186 return &self.inner.servers;
187 }
188
189 pub fn global_security(&self) -> Option<&[openapiv3::SecurityRequirement]> {
192 return self.inner.security.as_deref();
193 }
194
195 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 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(¤t) {
218 origin = Some(file.to_owned());
219 }
220 let name = ref_component_name(¤t, "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(), ¤t, 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 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(¤t) {
250 origin = Some(file.to_owned());
251 }
252 let name = ref_component_name(¤t, "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(), ¤t, 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 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(¤t) {
282 origin = Some(file.to_owned());
283 }
284 let name = ref_component_name(¤t, "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(), ¤t, 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 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(¤t).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 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(¤t).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(¤t, "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, ¤t, 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 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 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 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 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 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 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
487fn 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 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
551fn 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
564fn 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
577fn 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
600fn 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
620fn 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
630fn 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
674fn 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
686fn 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 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
728fn 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
753pub 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
768pub 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
782pub 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
801pub 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 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 #[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(¶m.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 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 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(¶m.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(¶m.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 #[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 #[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 #[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}