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 crate::coverage::check(&document, &value)?;
94 let inner: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
95 return Error::ParseSpec {
96 path: document.clone(),
97 source,
98 };
99 })?;
100 return Ok(Spec {
101 inner,
102 source: path.to_path_buf(),
103 docs: RefCell::new(HashMap::new()),
104 });
105 }
106
107 pub fn from_parts(inner: OpenAPI, source: PathBuf) -> Self {
109 return Spec {
110 inner,
111 source,
112 docs: RefCell::new(HashMap::new()),
113 };
114 }
115
116 fn document_for(&self, file: &str) -> Result<Rc<OpenAPI>> {
119 let base = self.source.parent().unwrap_or_else(|| {
120 return Path::new(".");
121 });
122 let path = base.join(file);
123 if let Some(doc) = self.docs.borrow().get(&path) {
124 return Ok(Rc::clone(doc));
125 }
126 let text = std::fs::read_to_string(&path).map_err(|source| {
127 return Error::ReadRefFile {
128 file: file.to_owned(),
129 source,
130 };
131 })?;
132 let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
133 return Error::ParseRefFile {
134 file: file.to_owned(),
135 source,
136 };
137 })?;
138 check_spec_version(file, &value)?;
143 check_top_level_keys(&value)?;
144 crate::coverage::check(file, &value)?;
145 let parsed: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
146 return Error::ParseRefFile {
147 file: file.to_owned(),
148 source,
149 };
150 })?;
151 let doc = Rc::new(parsed);
152 self.docs.borrow_mut().insert(path, Rc::clone(&doc));
153 return Ok(doc);
154 }
155
156 pub fn source(&self) -> &Path {
158 return &self.source;
159 }
160
161 pub fn apply_filters(&mut self, opts: &crate::config::OutputOptions) {
164 crate::filter::apply(&mut self.inner, opts);
165 }
166
167 pub fn schemas(&self) -> &IndexMap<String, ReferenceOr<Schema>> {
169 let empty = EMPTY_SCHEMAS.get_or_init(IndexMap::new);
170 let schemas = self
171 .inner
172 .components
173 .as_ref()
174 .map(|c| {
175 return &c.schemas;
176 })
177 .unwrap_or(empty);
178 return schemas;
179 }
180
181 pub fn paths(&self) -> &openapiv3::Paths {
183 return &self.inner.paths;
184 }
185
186 pub fn servers(&self) -> &[openapiv3::Server] {
188 return &self.inner.servers;
189 }
190
191 pub fn global_security(&self) -> Option<&[openapiv3::SecurityRequirement]> {
194 return self.inner.security.as_deref();
195 }
196
197 pub fn security_schemes(&self) -> &IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>> {
200 let empty = EMPTY_SECURITY_SCHEMES.get_or_init(IndexMap::new);
201 let schemes = self
202 .inner
203 .components
204 .as_ref()
205 .map(|c| {
206 return &c.security_schemes;
207 })
208 .unwrap_or(empty);
209 return schemes;
210 }
211
212 pub fn resolve_response(&self, reference: &str) -> Result<Resolved<Response>> {
216 let mut current = reference.to_owned();
217 let mut origin: Option<String> = None;
218 for _ in 0..MAX_REF_DEPTH {
219 if let Some(file) = ref_file_part(¤t) {
220 origin = Some(file.to_owned());
221 }
222 let name = ref_component_name(¤t, "responses").ok_or_else(|| {
223 return Error::UnsupportedRef {
224 reference: current.clone(),
225 reason: "only `#/components/responses/<name>` references are supported".to_owned(),
226 };
227 })?;
228 let entry = self.component_response(origin.as_deref(), ¤t, name)?;
229 match entry {
230 ReferenceOr::Item(response) => {
231 return Ok(Resolved {
232 value: response,
233 origin,
234 });
235 }
236 ReferenceOr::Reference { reference } => {
237 current = reference;
238 }
239 }
240 }
241 return Err(Error::UnresolvedRef(reference.to_owned()));
242 }
243
244 pub fn resolve_parameter(&self, reference: &str) -> Result<Resolved<Parameter>> {
248 let mut current = reference.to_owned();
249 let mut origin: Option<String> = None;
250 for _ in 0..MAX_REF_DEPTH {
251 if let Some(file) = ref_file_part(¤t) {
252 origin = Some(file.to_owned());
253 }
254 let name = ref_component_name(¤t, "parameters").ok_or_else(|| {
255 return Error::UnsupportedRef {
256 reference: current.clone(),
257 reason: "only `#/components/parameters/<name>` references are supported".to_owned(),
258 };
259 })?;
260 let entry = self.component_parameter(origin.as_deref(), ¤t, name)?;
261 match entry {
262 ReferenceOr::Item(parameter) => {
263 return Ok(Resolved {
264 value: parameter,
265 origin,
266 });
267 }
268 ReferenceOr::Reference { reference } => {
269 current = reference;
270 }
271 }
272 }
273 return Err(Error::UnresolvedRef(reference.to_owned()));
274 }
275
276 pub fn resolve_request_body(&self, reference: &str) -> Result<Resolved<RequestBody>> {
280 let mut current = reference.to_owned();
281 let mut origin: Option<String> = None;
282 for _ in 0..MAX_REF_DEPTH {
283 if let Some(file) = ref_file_part(¤t) {
284 origin = Some(file.to_owned());
285 }
286 let name = ref_component_name(¤t, "requestBodies").ok_or_else(|| {
287 return Error::UnsupportedRef {
288 reference: current.clone(),
289 reason: "only `#/components/requestBodies/<name>` references are supported".to_owned(),
290 };
291 })?;
292 let entry = self.component_request_body(origin.as_deref(), ¤t, name)?;
293 match entry {
294 ReferenceOr::Item(body) => {
295 return Ok(Resolved { value: body, origin });
296 }
297 ReferenceOr::Reference { reference } => {
298 current = reference;
299 }
300 }
301 }
302 return Err(Error::UnresolvedRef(reference.to_owned()));
303 }
304
305 pub fn resolve(&self, reference: &str) -> Result<&Schema> {
308 let mut current = reference.to_owned();
309 for _ in 0..MAX_REF_DEPTH {
310 let name = ref_target_name(¤t).ok_or_else(|| {
311 return Error::UnsupportedRef {
312 reference: current.clone(),
313 reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
314 };
315 })?;
316 let entry = self
317 .schemas()
318 .get(name)
319 .ok_or_else(|| return Error::UnresolvedRef(current.clone()))?;
320 match entry {
321 ReferenceOr::Item(schema) => {
322 return Ok(schema);
323 }
324 ReferenceOr::Reference { reference } => {
325 current = reference.clone();
326 }
327 }
328 }
329 return Err(Error::UnresolvedRef(reference.to_owned()));
330 }
331
332 pub fn resolve_schema(&self, origin: Option<&str>, reference: &str) -> Result<Schema> {
341 let mut current = reference.to_owned();
342 for _ in 0..MAX_REF_DEPTH {
343 if ref_file_part(¤t).is_some() {
344 return Err(Error::UnsupportedRef {
345 reference: current.clone(),
346 reason: "cross-file schema `$ref`s are not supported here".to_owned(),
347 });
348 }
349 let name = ref_component_name(¤t, "schemas").ok_or_else(|| {
350 return Error::UnsupportedRef {
351 reference: current.clone(),
352 reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
353 };
354 })?;
355 let entry = self.component_schema(origin, ¤t, name)?;
356 match entry {
357 ReferenceOr::Item(schema) => {
358 return Ok(schema);
359 }
360 ReferenceOr::Reference { reference } => {
361 current = reference;
362 }
363 }
364 }
365 return Err(Error::UnresolvedRef(reference.to_owned()));
366 }
367
368 fn component_lookup<T>(
376 &self,
377 origin: Option<&str>,
378 reference: &str,
379 select: impl Fn(&OpenAPI) -> Option<ReferenceOr<T>>,
380 ) -> Result<ReferenceOr<T>> {
381 let entry = match origin {
382 None => select(&self.inner),
383 Some(file) => {
384 let doc = self.document_for(file)?;
385 select(doc.as_ref())
386 }
387 };
388 return entry.ok_or_else(|| return Error::UnresolvedRef(reference.to_owned()));
389 }
390
391 fn component_response(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Response>> {
393 return self.component_lookup(origin, reference, |doc| {
394 return doc.components.as_ref().and_then(|components| {
395 return components.responses.get(name).cloned();
396 });
397 });
398 }
399
400 fn component_parameter(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Parameter>> {
402 return self.component_lookup(origin, reference, |doc| {
403 return doc.components.as_ref().and_then(|components| {
404 return components.parameters.get(name).cloned();
405 });
406 });
407 }
408
409 fn component_request_body(
411 &self,
412 origin: Option<&str>,
413 reference: &str,
414 name: &str,
415 ) -> Result<ReferenceOr<RequestBody>> {
416 return self.component_lookup(origin, reference, |doc| {
417 return doc.components.as_ref().and_then(|components| {
418 return components.request_bodies.get(name).cloned();
419 });
420 });
421 }
422
423 fn component_schema(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Schema>> {
425 return self.component_lookup(origin, reference, |doc| {
426 return doc.components.as_ref().and_then(|components| {
427 return components.schemas.get(name).cloned();
428 });
429 });
430 }
431
432 pub fn external_schema_name(&self, file: &str, name: &str, reference: &str) -> Result<String> {
453 let entry = self.component_schema(Some(file), reference, name)?;
454 let chosen = external_name_of(&entry, name)?;
455 let doc = self.document_for(file)?;
456 let schemas = doc.components.as_ref().map(|components| return &components.schemas);
457 let ident = crate::naming::to_ident(&chosen, crate::naming::Case::Pascal);
458 if direction_split_schemas(&doc).contains(name) {
459 let shape = ident.logical();
460 return Err(Error::UnsupportedRef {
461 reference: reference.to_owned(),
462 reason: format!(
463 "`{name}` in `{file}` marks a property `readOnly` or `writeOnly`, so the run that \
464 generates that file emits it as `{shape}{REQUEST_SUFFIX}` and `{shape}{RESPONSE_SUFFIX}`. \
465 This run cannot tell which of the two an `import-mapping` reference means. Declare \
466 the schema in this document instead, or drop the mark"
467 ),
468 });
469 }
470 for (other, other_entry) in schemas.into_iter().flatten() {
471 if other == name {
472 continue;
473 }
474 let taken = external_name_of(other_entry, other)?;
475 if crate::naming::to_ident(&taken, crate::naming::Case::Pascal).logical() == ident.logical() {
476 return Err(Error::UnsupportedRef {
477 reference: reference.to_owned(),
478 reason: format!(
479 "`{file}` gives `{name}` and `{other}` the one Rust name `{}`",
480 ident.logical()
481 ),
482 });
483 }
484 }
485 return Ok(chosen);
486 }
487}
488
489fn direction_split_schemas(doc: &OpenAPI) -> std::collections::BTreeSet<String> {
502 let mut marked = std::collections::BTreeSet::new();
503 let Some(components) = doc.components.as_ref() else {
504 return marked;
505 };
506
507 let mut directional = std::collections::BTreeSet::new();
508 for (name, entry) in &components.schemas {
509 if let ReferenceOr::Item(schema) = entry
510 && (schema.schema_data.read_only || schema.schema_data.write_only)
511 {
512 directional.insert(name.clone());
513 }
514 }
515
516 let mut referrers: HashMap<String, Vec<String>> = HashMap::new();
517 for (name, entry) in &components.schemas {
518 match entry {
519 ReferenceOr::Item(schema) => {
520 if schema_marks_a_direction(schema, &directional) {
521 marked.insert(name.clone());
522 }
523 let mut targets = Vec::new();
524 schema_local_refs(schema, &mut targets);
525 for target in targets {
526 referrers.entry(target).or_default().push(name.clone());
527 }
528 }
529 ReferenceOr::Reference { reference } => {
532 if let Some(target) = ref_target_name(reference) {
533 referrers.entry(target.to_owned()).or_default().push(name.clone());
534 }
535 }
536 }
537 }
538
539 let mut stack: Vec<String> = marked.iter().cloned().collect();
540 while let Some(name) = stack.pop() {
541 let Some(parents) = referrers.get(&name) else {
542 continue;
543 };
544 for parent in parents {
545 if marked.insert(parent.clone()) {
546 stack.push(parent.clone());
547 }
548 }
549 }
550 return marked;
551}
552
553fn schema_marks_a_direction(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
559 let mut found = declares_a_directional_property(schema, directional);
560 walk_inline_schemas(schema, &mut |inner| {
561 found = found || declares_a_directional_property(inner, directional);
562 });
563 return found;
564}
565
566fn declares_a_directional_property(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
568 return object_properties(schema).iter().any(|entry| {
569 return match entry {
570 ReferenceOr::Item(inner) => inner.schema_data.read_only || inner.schema_data.write_only,
571 ReferenceOr::Reference { reference } => {
572 return ref_file_part(reference).is_none()
573 && ref_component_name(reference, "schemas").is_some_and(|name| return directional.contains(name));
574 }
575 };
576 });
577}
578
579fn object_properties(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
581 let unbox = |entry: &ReferenceOr<Box<Schema>>| {
582 return match entry {
583 ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
584 ReferenceOr::Reference { reference } => ReferenceOr::Reference {
585 reference: reference.clone(),
586 },
587 };
588 };
589 return match &schema.schema_kind {
590 openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
591 return object.properties.values().map(&unbox).collect();
592 }
593 openapiv3::SchemaKind::Any(any) => return any.properties.values().map(&unbox).collect(),
594 openapiv3::SchemaKind::Type(_)
595 | openapiv3::SchemaKind::OneOf { .. }
596 | openapiv3::SchemaKind::AllOf { .. }
597 | openapiv3::SchemaKind::AnyOf { .. }
598 | openapiv3::SchemaKind::Not { .. } => Vec::new(),
599 };
600}
601
602fn schema_local_refs(schema: &Schema, out: &mut Vec<String>) {
604 let mut collect = |entry: &ReferenceOr<Schema>| {
605 if let ReferenceOr::Reference { reference } = entry
606 && ref_file_part(reference).is_none()
607 && let Some(name) = ref_component_name(reference, "schemas")
608 {
609 out.push(name.to_owned());
610 }
611 };
612 for entry in inline_members(schema) {
613 collect(&entry);
614 }
615 walk_inline_schemas(schema, &mut |inner| {
616 for entry in inline_members(inner) {
617 collect(&entry);
618 }
619 });
620}
621
622fn walk_inline_schemas(schema: &Schema, visit: &mut impl FnMut(&Schema)) {
624 for entry in inline_members(schema) {
625 if let ReferenceOr::Item(inner) = entry {
626 visit(&inner);
627 walk_inline_schemas(&inner, visit);
628 }
629 }
630}
631
632fn inline_members(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
635 let unbox = |entry: &ReferenceOr<Box<Schema>>| {
636 return match entry {
637 ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
638 ReferenceOr::Reference { reference } => ReferenceOr::Reference {
639 reference: reference.clone(),
640 },
641 };
642 };
643 let additional = |entry: &Option<openapiv3::AdditionalProperties>| {
644 return match entry {
645 Some(openapiv3::AdditionalProperties::Schema(inner)) => vec![(**inner).clone()],
646 Some(openapiv3::AdditionalProperties::Any(_)) | None => Vec::new(),
647 };
648 };
649 let mut members = Vec::new();
650 match &schema.schema_kind {
651 openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
652 members.extend(object.properties.values().map(&unbox));
653 members.extend(additional(&object.additional_properties));
654 }
655 openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
656 members.extend(array.items.as_ref().map(unbox));
657 }
658 openapiv3::SchemaKind::Type(_) => {}
659 openapiv3::SchemaKind::OneOf { one_of } => members.extend(one_of.iter().cloned()),
660 openapiv3::SchemaKind::AllOf { all_of } => members.extend(all_of.iter().cloned()),
661 openapiv3::SchemaKind::AnyOf { any_of } => members.extend(any_of.iter().cloned()),
662 openapiv3::SchemaKind::Not { not } => members.push((**not).clone()),
663 openapiv3::SchemaKind::Any(any) => {
664 members.extend(any.properties.values().map(&unbox));
665 members.extend(additional(&any.additional_properties));
666 members.extend(any.items.as_ref().map(unbox));
667 members.extend(any.one_of.iter().cloned());
668 members.extend(any.all_of.iter().cloned());
669 members.extend(any.any_of.iter().cloned());
670 members.extend(any.not.as_ref().map(|not| return (**not).clone()));
671 }
672 }
673 return members;
674}
675
676fn external_name_of(entry: &ReferenceOr<Schema>, name: &str) -> Result<String> {
679 let renamed = match entry {
680 ReferenceOr::Item(schema) => {
681 crate::lower::extension::str_value(&schema.schema_data.extensions, crate::naming::X_RUST_NAME, name)?
682 }
683 ReferenceOr::Reference { .. } => None,
684 };
685 return Ok(renamed.unwrap_or(name).to_owned());
686}
687
688fn check_spec_version(document: &str, value: &serde_yaml::Value) -> Result<()> {
704 let Some(version) = value.get("openapi").and_then(serde_yaml::Value::as_str) else {
705 return Ok(());
706 };
707 let version = version.trim();
708 let supported = SUPPORTED_SPEC_VERSIONS.iter().any(|minor| {
711 return version == *minor || version.starts_with(&format!("{minor}."));
712 });
713 if supported {
714 return Ok(());
715 }
716 let reads = SUPPORTED_SPEC_VERSIONS
717 .iter()
718 .map(|minor| {
719 return format!("{minor}.x");
720 })
721 .collect::<Vec<_>>()
722 .join(", ");
723 return Err(Error::UnsupportedSpecVersion {
724 document: document.to_owned(),
725 version: version.to_owned(),
726 hint: format!("The generator reads OpenAPI {reads} only."),
727 });
728}
729
730fn check_top_level_keys(value: &serde_yaml::Value) -> Result<()> {
738 let Some(mapping) = value.as_mapping() else {
739 return Ok(());
740 };
741 for key in UNSUPPORTED_TOP_LEVEL_KEYS {
742 if mapping.contains_key(serde_yaml::Value::String(key.to_owned())) {
743 return Err(Error::UnsupportedSpecKey {
744 key: key.to_owned(),
745 reason: "declares operations the generator cannot emit".to_owned(),
746 hint: format!(
747 "Remove `{key}:`, or move its operations under `paths:`. Silently ignoring it emits no handler for any operation it holds."
748 ),
749 });
750 }
751 }
752 return Ok(());
753}
754
755pub fn ref_target_name(reference: &str) -> Option<&str> {
764 if ref_file_part(reference).is_some() {
765 return None;
766 }
767 return ref_component_name(reference, "schemas");
768}
769
770pub fn schema_ref_reason(reference: &str, site: &str) -> String {
778 if ref_file_part(reference).is_some() {
779 return format!("a cross-file ref does not resolve at {site}");
780 }
781 return format!("{site} must reference `#/components/schemas/<name>`");
782}
783
784pub fn ref_component_name<'a>(reference: &'a str, kind: &str) -> Option<&'a str> {
788 let fragment = reference.split('#').nth(1).unwrap_or(reference);
789 let prefix = match kind {
790 "schemas" => "/components/schemas/",
791 "responses" => "/components/responses/",
792 "parameters" => "/components/parameters/",
793 "requestBodies" => "/components/requestBodies/",
794 _ => return None,
795 };
796 let name = fragment.strip_prefix(prefix)?;
797 if name.is_empty() || name.contains('/') {
798 return None;
799 }
800 return Some(name);
801}
802
803pub fn ref_file_part(reference: &str) -> Option<&str> {
806 return match reference.split_once('#') {
807 Some((file, _fragment)) if !file.is_empty() => Some(file),
808 _ => None,
809 };
810}
811
812#[cfg(test)]
813mod tests {
814 use super::*;
815
816 struct TestDir {
817 path: PathBuf,
818 }
819
820 impl TestDir {
821 fn new(test_name: &str) -> Self {
822 let unique = format!(
823 "oapi-codegen-loader-{test_name}-{}-{}",
824 std::process::id(),
825 std::time::SystemTime::now()
826 .duration_since(std::time::UNIX_EPOCH)
827 .expect("system clock should be after Unix epoch")
828 .as_nanos(),
829 );
830 let path = std::env::temp_dir().join(unique);
831 std::fs::create_dir_all(&path).expect("create test directory");
832 return Self { path };
833 }
834
835 fn write(&self, file: &str, contents: &str) -> PathBuf {
836 let path = self.path.join(file);
837 if let Some(parent) = path.parent() {
838 std::fs::create_dir_all(parent).expect("create parent directory");
839 }
840 std::fs::write(&path, contents).expect("write test file");
841 return path;
842 }
843 }
844
845 impl Drop for TestDir {
846 fn drop(&mut self) {
847 let _ = std::fs::remove_dir_all(&self.path);
848 }
849 }
850
851 fn parse_openapi(yaml: &str) -> OpenAPI {
852 return serde_yaml::from_str(yaml).expect("parse OpenAPI document");
853 }
854
855 fn assert_query_parameter_name(parameter: &Parameter, expected: &str) {
856 match parameter {
857 Parameter::Query { parameter_data, .. } => {
858 assert_eq!(parameter_data.name, expected);
859 }
860 _ => panic!("expected a query parameter"),
861 }
862 }
863
864 fn minimal_doc() -> &'static str {
865 return "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\n";
866 }
867
868 fn shared_parameter_doc(name: &str) -> String {
869 return format!(
870 "openapi: 3.0.3\ninfo:\n title: shared\n version: '1'\npaths: {{}}\ncomponents:\n parameters:\n PageSize:\n name: {name}\n in: query\n schema:\n type: integer\n",
871 );
872 }
873
874 #[test]
875 fn ref_target_name_is_same_document_schemas_only() {
876 assert_eq!(ref_target_name("#/components/schemas/Foo"), Some("Foo"));
877 assert_eq!(
880 ref_target_name("schemas/common.yaml#/components/schemas/ErrorResponse"),
881 None
882 );
883 assert_eq!(ref_target_name("#/components/responses/Bar"), None);
884 }
885
886 #[test]
889 fn a_schema_ref_reason_names_the_fault() {
890 assert_eq!(
891 schema_ref_reason("common.yaml#/components/schemas/X", "a property"),
892 "a cross-file ref does not resolve at a property"
893 );
894 assert_eq!(
895 schema_ref_reason("#/components/responses/Bar", "a property"),
896 "a property must reference `#/components/schemas/<name>`"
897 );
898 }
899
900 #[test]
901 fn extracts_component_responses_and_file_parts() {
902 assert_eq!(
903 ref_component_name("#/components/responses/Bar", "responses"),
904 Some("Bar")
905 );
906 assert_eq!(ref_component_name("#/components/schemas/Foo", "responses"), None);
907 assert_eq!(ref_file_part("#/components/schemas/Foo"), None);
908 assert_eq!(
909 ref_file_part("schemas/common.yaml#/components/schemas/ErrorResponse"),
910 Some("schemas/common.yaml"),
911 );
912 }
913
914 #[test]
915 fn ref_component_name_recognizes_parameters_and_request_bodies() {
916 assert_eq!(
917 ref_component_name("#/components/parameters/PageSize", "parameters"),
918 Some("PageSize")
919 );
920 assert_eq!(
921 ref_component_name("#/components/requestBodies/CreateWidget", "requestBodies"),
922 Some("CreateWidget")
923 );
924 assert_eq!(ref_component_name("#/components/schemas/Foo", "parameters"), None);
925 }
926
927 #[test]
928 fn resolves_same_document_component_parameter() {
929 let yaml = "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n parameters:\n PageSize:\n name: pageSize\n in: query\n schema:\n type: integer\n";
930 let doc = parse_openapi(yaml);
931 let spec = Spec::from_parts(doc, std::path::PathBuf::from("inline.yaml"));
932 let param = spec
933 .resolve_parameter("#/components/parameters/PageSize")
934 .expect("resolve");
935 assert!(param.origin.is_none());
936 assert_query_parameter_name(¶m.value, "pageSize");
937 }
938
939 #[test]
940 fn errors_on_cross_file_ref_to_missing_file() {
941 let source = std::env::temp_dir().join(format!(
942 "oapi-codegen-loader-missing-{}-{}.yaml",
943 std::process::id(),
944 std::time::SystemTime::now()
945 .duration_since(std::time::UNIX_EPOCH)
946 .expect("system clock should be after Unix epoch")
947 .as_nanos(),
948 ));
949 let spec = Spec::from_parts(parse_openapi(minimal_doc()), source);
950 let result = spec.resolve_parameter("common.yaml#/components/parameters/PageSize");
951 assert!(matches!(result, Err(Error::ReadRefFile { .. })));
952 }
953
954 #[test]
955 fn unresolved_component_error_preserves_the_full_reference() {
956 let yaml = "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n parameters: {}\n";
959 let spec = Spec::from_parts(parse_openapi(yaml), std::path::PathBuf::from("inline.yaml"));
960 let result = spec.resolve_parameter("#/components/parameters/Missing");
961 match result {
962 Err(Error::UnresolvedRef(reference)) => {
963 assert_eq!(reference, "#/components/parameters/Missing");
964 }
965 other => panic!("expected UnresolvedRef, got {other:?}"),
966 }
967
968 let dir = TestDir::new("unresolved-cross-file");
970 let main = dir.write("main.yaml", minimal_doc());
971 dir.write("shared.yaml", minimal_doc());
972 let spec = Spec::load(&main).expect("load main spec");
973 let result = spec.resolve_parameter("shared.yaml#/components/parameters/Missing");
974 match result {
975 Err(Error::UnresolvedRef(reference)) => {
976 assert_eq!(reference, "shared.yaml#/components/parameters/Missing");
977 }
978 other => panic!("expected UnresolvedRef, got {other:?}"),
979 }
980 }
981
982 #[test]
983 fn document_for_reads_sibling_and_caches() {
984 let dir = TestDir::new("document-for-caches");
985 let main = dir.write("main.yaml", minimal_doc());
986 dir.write("shared.yaml", &shared_parameter_doc("pageSize"));
987
988 let spec = Spec::load(&main).expect("load main spec");
989 let param = spec
990 .resolve_parameter("shared.yaml#/components/parameters/PageSize")
991 .expect("resolve cross-file parameter");
992 assert_eq!(param.origin.as_deref(), Some("shared.yaml"));
993 assert_query_parameter_name(¶m.value, "pageSize");
994 assert_eq!(spec.docs.borrow().len(), 1);
995
996 let second = spec
997 .resolve_parameter("shared.yaml#/components/parameters/PageSize")
998 .expect("resolve cached cross-file parameter");
999 assert_eq!(second.origin.as_deref(), Some("shared.yaml"));
1000 assert_query_parameter_name(&second.value, "pageSize");
1001 assert_eq!(spec.docs.borrow().len(), 1);
1002 }
1003
1004 #[test]
1005 fn cross_file_chain_across_two_files_resolves() {
1006 let dir = TestDir::new("cross-file-chain");
1007 let main = dir.write("main.yaml", minimal_doc());
1008 dir.write(
1009 "a.yaml",
1010 "openapi: 3.0.3\ninfo:\n title: a\n version: '1'\npaths: {}\ncomponents:\n parameters:\n X:\n $ref: \"b.yaml#/components/parameters/Y\"\n",
1011 );
1012 dir.write(
1013 "b.yaml",
1014 "openapi: 3.0.3\ninfo:\n title: b\n version: '1'\npaths: {}\ncomponents:\n parameters:\n Y:\n name: cursor\n in: query\n schema:\n type: string\n",
1015 );
1016
1017 let spec = Spec::load(&main).expect("load main spec");
1018 let param = spec
1019 .resolve_parameter("a.yaml#/components/parameters/X")
1020 .expect("resolve cross-file chain");
1021 assert_eq!(param.origin.as_deref(), Some("b.yaml"));
1022 assert_query_parameter_name(¶m.value, "cursor");
1023 }
1024
1025 #[test]
1026 fn cross_file_cycle_terminates() {
1027 let dir = TestDir::new("cross-file-cycle");
1028 let main = dir.write("main.yaml", minimal_doc());
1029 dir.write(
1030 "a.yaml",
1031 "openapi: 3.0.3\ninfo:\n title: a\n version: '1'\npaths: {}\ncomponents:\n parameters:\n X:\n $ref: \"b.yaml#/components/parameters/Y\"\n",
1032 );
1033 dir.write(
1034 "b.yaml",
1035 "openapi: 3.0.3\ninfo:\n title: b\n version: '1'\npaths: {}\ncomponents:\n parameters:\n Y:\n $ref: \"a.yaml#/components/parameters/X\"\n",
1036 );
1037
1038 let spec = Spec::load(&main).expect("load main spec");
1039 let result = spec.resolve_parameter("a.yaml#/components/parameters/X");
1040 assert!(matches!(result, Err(Error::UnresolvedRef(_))));
1041 }
1042
1043 #[test]
1044 fn resolve_schema_against_origin() {
1045 let dir = TestDir::new("resolve-schema-origin");
1046 let main = dir.write("main.yaml", minimal_doc());
1047 dir.write(
1048 "shared.yaml",
1049 "openapi: 3.0.3\ninfo:\n title: shared\n version: '1'\npaths: {}\ncomponents:\n schemas:\n PageInfo:\n type: integer\n format: int32\n",
1050 );
1051
1052 let spec = Spec::load(&main).expect("load main spec");
1053 let schema = spec
1054 .resolve_schema(Some("shared.yaml"), "#/components/schemas/PageInfo")
1055 .expect("resolve schema from origin");
1056 assert!(matches!(
1057 schema.schema_kind,
1058 openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_))
1059 ));
1060 }
1061
1062 #[test]
1068 fn a_schema_level_mark_splits_only_the_referrers() {
1069 let doc: OpenAPI = serde_yaml::from_str(
1070 "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Marked:\n type: string\n readOnly: true\n Holder:\n type: object\n properties:\n a:\n $ref: '#/components/schemas/Marked'\n Bystander:\n type: object\n properties:\n b:\n type: string\n",
1071 )
1072 .expect("parse doc");
1073
1074 let split = direction_split_schemas(&doc);
1075 assert!(split.contains("Holder"), "a property that names a marked schema splits");
1076 assert!(!split.contains("Marked"), "a marked primitive keeps its one name");
1077 assert!(!split.contains("Bystander"), "an unrelated schema keeps its one name");
1078 }
1079
1080 #[test]
1086 fn an_alias_to_a_split_model_splits_as_well() {
1087 let doc: OpenAPI = serde_yaml::from_str(
1088 "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Marked:\n type: object\n properties:\n id:\n type: string\n readOnly: true\n Alias:\n $ref: '#/components/schemas/Marked'\n Plain:\n type: object\n properties:\n a:\n type: string\n PlainAlias:\n $ref: '#/components/schemas/Plain'\n",
1089 )
1090 .expect("parse doc");
1091
1092 let split = direction_split_schemas(&doc);
1093 assert!(split.contains("Marked"), "the model that declares the mark splits");
1094 assert!(split.contains("Alias"), "an alias to a split model splits");
1095 assert!(!split.contains("Plain"), "an unmarked model keeps its one name");
1096 assert!(
1097 !split.contains("PlainAlias"),
1098 "an alias to an unmarked model keeps its one name"
1099 );
1100 }
1101
1102 #[test]
1105 fn a_property_mark_splits_the_declaring_model_and_its_referrers() {
1106 let doc: OpenAPI = serde_yaml::from_str(
1107 "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Leaf:\n type: object\n properties:\n id:\n type: string\n readOnly: true\n Parent:\n type: object\n properties:\n leaf:\n $ref: '#/components/schemas/Leaf'\n",
1108 )
1109 .expect("parse doc");
1110
1111 let split = direction_split_schemas(&doc);
1112 assert!(split.contains("Leaf"), "the model that declares the mark splits");
1113 assert!(split.contains("Parent"), "a model that reaches the mark splits");
1114 }
1115}