1use std::borrow::Cow;
2use std::{
3 collections::{BTreeMap, BTreeSet, HashMap},
4 fs,
5 path::Path,
6 sync::OnceLock,
7 time::Instant,
8};
9
10use anyhow::{Context, Result, anyhow, bail};
11use arvalez_ir::{
12 Attributes, CoreIr, Field, HttpMethod, Model, Operation, Parameter, ParameterLocation,
13 RequestBody, Response, SourceRef, TypeRef, validate_ir,
14};
15use indexmap::IndexMap;
16use serde::Deserialize;
17use serde_json::{Value, json};
18
19#[derive(Debug, Clone, Copy, Default)]
20pub struct LoadOpenApiOptions {
21 pub ignore_unhandled: bool,
22 pub emit_timings: bool,
23}
24
25#[derive(Debug, Clone)]
30pub struct OpenApiDiagnostic {
31 pub kind: DiagnosticKind,
33 pub pointer: Option<String>,
35 pub source_preview: Option<String>,
37 pub context: Option<String>,
39 pub line: Option<usize>,
41}
42
43impl OpenApiDiagnostic {
44 pub fn from_pointer(
45 kind: DiagnosticKind,
46 pointer: impl Into<String>,
47 source_preview: Option<String>,
48 line: Option<usize>,
49 ) -> Self {
50 OpenApiDiagnostic {
51 kind,
52 pointer: Some(pointer.into()),
53 source_preview,
54 context: None,
55 line,
56 }
57 }
58
59 pub fn from_named_context(kind: DiagnosticKind, context: impl Into<String>) -> Self {
60 OpenApiDiagnostic {
61 kind,
62 pointer: None,
63 source_preview: None,
64 context: Some(context.into()),
65 line: None,
66 }
67 }
68
69 pub fn simple(kind: DiagnosticKind) -> Self {
70 OpenApiDiagnostic {
71 kind,
72 pointer: None,
73 source_preview: None,
74 context: None,
75 line: None,
76 }
77 }
78
79 pub fn note(&self) -> Option<&str> {
81 let note = self.kind.note_text();
82 if note.is_empty() { None } else { Some(note) }
83 }
84
85 pub fn classify(&self) -> (&'static str, String) {
91 match &self.kind {
92 DiagnosticKind::UnknownSchemaKeyword { keyword } => {
93 ("unsupported_schema_keyword", keyword.clone())
94 }
95 DiagnosticKind::UnsupportedSchemaKeyword { keyword } => (
96 Self::unsupported_kind_for_pointer(self.pointer.as_deref(), keyword),
97 keyword.clone(),
98 ),
99 DiagnosticKind::UnsupportedSchemaType { schema_type } => {
100 ("unsupported_schema_type", schema_type.clone())
101 }
102 DiagnosticKind::UnsupportedSchemaShape => (
103 "unsupported_schema_shape",
104 self.pointer
105 .as_deref()
106 .map(diagnostic_pointer_tail)
107 .unwrap_or_else(|| "schema_shape".into()),
108 ),
109 DiagnosticKind::UnsupportedReference { reference } => {
110 ("unsupported_reference", categorize_reference(reference))
111 }
112 DiagnosticKind::AllOfRecursiveCycle { .. } => {
113 ("unsupported_all_of_merge", "recursive_cycle".into())
114 }
115 DiagnosticKind::RecursiveParameterCycle { .. } => (
116 "invalid_openapi_document",
117 "recursive_parameter_cycle".into(),
118 ),
119 DiagnosticKind::RecursiveRequestBodyCycle { .. } => (
120 "invalid_openapi_document",
121 "recursive_request_body_cycle".into(),
122 ),
123 DiagnosticKind::IncompatibleAllOfField { field } => {
124 ("unsupported_all_of_merge", field.clone())
125 }
126 DiagnosticKind::EmptyRequestBodyContent => {
127 ("unsupported_request_body_shape", "empty_content".into())
128 }
129 DiagnosticKind::EmptyParameterName { .. } => {
130 ("invalid_openapi_document", "empty_parameter_name".into())
131 }
132 DiagnosticKind::EmptyPropertyKey { .. } => {
133 ("invalid_openapi_document", "empty_property_key".into())
134 }
135 DiagnosticKind::ParameterMissingSchema { name } => (
136 "invalid_openapi_document",
137 normalize_diagnostic_feature(name),
138 ),
139 DiagnosticKind::UnsupportedParameterLocation { name } => (
140 "invalid_openapi_document",
141 normalize_diagnostic_feature(name),
142 ),
143 DiagnosticKind::MultipleRequestBodyDeclarations { .. } => (
144 "invalid_openapi_document",
145 "multiple_request_body_declarations".into(),
146 ),
147 DiagnosticKind::BodyParameterMissingSchema { name } => (
148 "invalid_openapi_document",
149 normalize_diagnostic_feature(name),
150 ),
151 DiagnosticKind::FormDataParameterMissingSchema { name } => (
152 "invalid_openapi_document",
153 normalize_diagnostic_feature(name),
154 ),
155 }
156 }
157
158 pub fn unsupported_kind_for_pointer(pointer: Option<&str>, feature: &str) -> &'static str {
159 if matches!(
160 feature,
161 "allOf" | "anyOf" | "oneOf" | "not" | "discriminator" | "const"
162 ) {
163 return "unsupported_schema_keyword";
164 }
165 match pointer {
166 Some(p)
167 if p.contains("/components/schemas/")
168 || p.contains("/properties/")
169 || p.ends_with("/schema")
170 || p.contains("/items/") =>
171 {
172 "unsupported_schema_keyword"
173 }
174 Some(p) if p.contains("/parameters/") => "unsupported_parameter_feature",
175 Some(p) if p.contains("/responses/") => "unsupported_response_feature",
176 Some(p) if p.contains("/requestBody/") => "unsupported_request_body_feature",
177 _ => "unsupported_feature",
178 }
179 }
180}
181
182pub fn categorize_reference(reference: &str) -> String {
183 if !reference.starts_with('#') {
185 return "external".into();
186 }
187 let inner = reference.strip_prefix("#/").unwrap_or("");
190 let structural: Vec<&str> = inner
191 .split('/')
192 .filter(|s| !s.is_empty() && !s.chars().all(|c| c.is_ascii_digit()) && !s.contains('~') && !s.contains('%'))
193 .take(2)
194 .collect();
195 if structural.is_empty() {
196 return "unknown".into();
197 }
198 structural.join("_")
199}
200
201pub fn diagnostic_pointer_tail(pointer: &str) -> String {
202 pointer
203 .trim_end_matches('/')
204 .rsplit('/')
205 .next()
206 .map(normalize_diagnostic_feature)
207 .unwrap_or_else(|| "schema_shape".into())
208}
209
210pub fn normalize_diagnostic_feature(value: &str) -> String {
211 value
212 .replace("~1", "/")
213 .replace("~0", "~")
214 .replace('.', "_")
215 .replace('/', "_")
216 .replace('`', "")
217}
218
219impl std::fmt::Display for OpenApiDiagnostic {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 let message = self.kind.message_text();
222 let note = self.kind.note_text();
223
224 if let Some(pointer) = &self.pointer {
225 write!(f, "OpenAPI document issue\nCaused by:\n {message}")?;
226 write!(f, "\n location: {pointer}")?;
227 if let Some(preview) = &self.source_preview {
228 write!(f, "\n preview:")?;
229 for line in preview.lines() {
230 write!(f, "\n {line}")?;
231 }
232 }
233 if !note.is_empty() {
234 write!(f, "\n note: {note}")?;
235 }
236 } else if let Some(context) = &self.context {
237 write!(f, "{context}: {message}")?;
238 if !note.is_empty() {
239 write!(f, "\nnote: {note}")?;
240 }
241 } else {
242 write!(f, "{message}")?;
243 if !note.is_empty() {
244 write!(f, "\nnote: {note}")?;
245 }
246 }
247 Ok(())
248 }
249}
250
251impl std::error::Error for OpenApiDiagnostic {}
252
253#[derive(Debug, Clone)]
255pub enum DiagnosticKind {
256 UnknownSchemaKeyword { keyword: String },
259 UnsupportedSchemaKeyword { keyword: String },
261 UnsupportedSchemaType { schema_type: String },
263 UnsupportedSchemaShape,
265 UnsupportedReference { reference: String },
268 AllOfRecursiveCycle { reference: String },
270 RecursiveParameterCycle { reference: String },
272 RecursiveRequestBodyCycle { reference: String },
274 IncompatibleAllOfField { field: String },
277 EmptyRequestBodyContent,
280 EmptyParameterName { counter: usize },
282 EmptyPropertyKey { counter: usize },
284 ParameterMissingSchema { name: String },
286 UnsupportedParameterLocation { name: String },
288 MultipleRequestBodyDeclarations { note: String },
290 BodyParameterMissingSchema { name: String },
292 FormDataParameterMissingSchema { name: String },
294}
295
296impl DiagnosticKind {
297 fn message_text(&self) -> String {
298 match self {
299 Self::UnknownSchemaKeyword { keyword } => format!("unknown schema keyword `{keyword}`"),
300 Self::UnsupportedSchemaKeyword { keyword } => {
301 format!("`{keyword}` is not supported yet")
302 }
303 Self::UnsupportedSchemaType { schema_type } => {
304 format!("unsupported schema type `{schema_type}`")
305 }
306 Self::UnsupportedSchemaShape => "schema shape is not supported yet".into(),
307 Self::UnsupportedReference { reference } => {
308 format!("unsupported reference `{reference}`")
309 }
310 Self::AllOfRecursiveCycle { reference } => {
311 format!("`allOf` contains a recursive reference cycle involving `{reference}`")
312 }
313 Self::RecursiveParameterCycle { reference } => {
314 format!("parameter reference contains a recursive cycle involving `{reference}`")
315 }
316 Self::RecursiveRequestBodyCycle { reference } => {
317 format!("request body reference contains a recursive cycle involving `{reference}`")
318 }
319 Self::IncompatibleAllOfField { field } => {
320 format!("`allOf` contains incompatible `{field}` declarations")
321 }
322 Self::EmptyRequestBodyContent => "request body has no content entries".into(),
323 Self::EmptyParameterName { counter } => {
324 format!("parameter #{counter} has an empty name")
325 }
326 Self::EmptyPropertyKey { counter } => format!("property #{counter} has an empty name"),
327 Self::ParameterMissingSchema { .. } => "parameter has no schema or type".into(),
328 Self::UnsupportedParameterLocation { .. } => "unsupported parameter location".into(),
329 Self::MultipleRequestBodyDeclarations { .. } => {
330 "multiple request body declarations are not supported".into()
331 }
332 Self::BodyParameterMissingSchema { .. } => "body parameter has no schema".into(),
333 Self::FormDataParameterMissingSchema { .. } => {
334 "formData parameter has no schema or type".into()
335 }
336 }
337 }
338
339 fn note_text(&self) -> &str {
340 match self {
341 Self::UnknownSchemaKeyword { .. }
342 | Self::UnsupportedSchemaKeyword { .. }
343 | Self::UnsupportedSchemaType { .. }
344 | Self::UnsupportedSchemaShape
345 | Self::AllOfRecursiveCycle { .. }
346 | Self::IncompatibleAllOfField { .. }
347 | Self::EmptyParameterName { .. }
348 | Self::EmptyPropertyKey { .. } => {
349 "Use `--ignore-unhandled` to turn this into a warning while keeping generation going."
350 }
351 Self::EmptyRequestBodyContent => {
352 "Arvalez defaulted this request body to `application/octet-stream` with an untyped payload."
353 }
354 Self::ParameterMissingSchema { .. } => {
355 "Arvalez currently expects non-body parameters to declare either `schema` (OpenAPI 3) or `type` (Swagger 2)."
356 }
357 Self::UnsupportedParameterLocation { .. } => {
358 "Arvalez currently supports path, query, header, and cookie parameters here."
359 }
360 Self::MultipleRequestBodyDeclarations { note } => note.as_str(),
361 Self::BodyParameterMissingSchema { .. } => {
362 "Swagger 2 `in: body` parameters must declare a `schema`."
363 }
364 Self::FormDataParameterMissingSchema { .. } => {
365 "Swagger 2 `in: formData` parameters must declare either a `type` or a `schema`."
366 }
367 Self::RecursiveParameterCycle { .. } => {
368 "Arvalez only supports acyclic parameter references."
369 }
370 Self::RecursiveRequestBodyCycle { .. } => {
371 "Arvalez only supports acyclic local `requestBody` references."
372 }
373 Self::UnsupportedReference { .. } => "",
374 }
375 }
376}
377
378#[derive(Debug, Clone)]
379pub struct OpenApiLoadResult {
380 pub ir: CoreIr,
381 pub warnings: Vec<OpenApiDiagnostic>,
382}
383
384pub fn load_openapi_to_ir(path: impl AsRef<Path>) -> Result<CoreIr> {
385 Ok(load_openapi_to_ir_with_options(path, LoadOpenApiOptions::default())?.ir)
386}
387
388pub fn load_openapi_to_ir_with_options(
389 path: impl AsRef<Path>,
390 options: LoadOpenApiOptions,
391) -> Result<OpenApiLoadResult> {
392 let path = path.as_ref();
393 let raw = measure_openapi_phase(options.emit_timings, "openapi_read", || {
394 fs::read_to_string(path)
395 .with_context(|| format!("failed to read OpenAPI document `{}`", path.display()))
396 })?;
397
398 let loaded = measure_openapi_phase(options.emit_timings, "openapi_parse", || {
399 match path.extension().and_then(|ext| ext.to_str()) {
400 Some("yaml") | Some("yml") => parse_yaml_openapi_document(path, &raw),
401 _ => parse_json_openapi_document(path, &raw),
402 }
403 })?;
404
405 OpenApiImporter::new(loaded.document, loaded.source, options).build_ir()
406}
407
408fn measure_openapi_phase<T, F>(enabled: bool, label: &str, task: F) -> Result<T>
409where
410 F: FnOnce() -> Result<T>,
411{
412 if enabled {
413 eprintln!("timing: starting {label}");
414 }
415 let started = Instant::now();
416 let value = task();
417 if enabled {
418 eprintln!(
419 "timing: {:<20} {}",
420 label,
421 format_duration(started.elapsed())
422 );
423 }
424 value
425}
426
427fn format_duration(duration: std::time::Duration) -> String {
428 let micros = duration.as_micros();
429 if micros < 1_000 {
430 format!("{micros}us")
431 } else if micros < 1_000_000 {
432 format!("{}ms", duration.as_millis())
433 } else {
434 format!("{:.2}s", duration.as_secs_f64())
435 }
436}
437
438fn deserialize_paths_map<'de, D>(
439 deserializer: D,
440) -> std::result::Result<BTreeMap<String, PathItem>, D::Error>
441where
442 D: serde::Deserializer<'de>,
443{
444 struct PathsMapVisitor;
450
451 impl<'de> serde::de::Visitor<'de> for PathsMapVisitor {
452 type Value = BTreeMap<String, PathItem>;
453
454 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
455 formatter.write_str("a map of path items")
456 }
457
458 fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
459 where
460 A: serde::de::MapAccess<'de>,
461 {
462 let mut result = BTreeMap::new();
463 while let Some(key) = map.next_key::<String>()? {
464 if key.starts_with("x-") {
465 map.next_value::<serde::de::IgnoredAny>()?;
467 } else {
468 let value = map.next_value::<PathItem>()?;
471 result.insert(key, value);
472 }
473 }
474 Ok(result)
475 }
476 }
477
478 deserializer.deserialize_map(PathsMapVisitor)
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482enum OpenApiVersion {
483 Swagger2,
484 OpenApi3,
485}
486
487fn detect_openapi_version(raw: &str) -> OpenApiVersion {
488 let sample = &raw[..raw.len().min(4096)];
490 if sample.contains("\"swagger\"")
491 || sample.starts_with("swagger:")
492 || sample.contains("\nswagger:")
493 {
494 OpenApiVersion::Swagger2
495 } else {
496 OpenApiVersion::OpenApi3
497 }
498}
499
500fn deserialize_json<T>(path: &Path, raw: &str) -> Result<T>
501where
502 T: for<'de> serde::Deserialize<'de>,
503{
504 let mut deserializer = serde_json::Deserializer::from_str(raw);
505 serde_path_to_error::deserialize(&mut deserializer).map_err(|error| {
506 let schema_path = error.path().to_string();
507 let inner = error.into_inner();
508 let line = inner.line();
509 let column = inner.column();
510 let message = inner.to_string();
511 anyhow!(format_openapi_deserialize_error(
512 "JSON",
513 path,
514 raw,
515 if schema_path.is_empty() {
516 None
517 } else {
518 Some(schema_path.as_str())
519 },
520 line,
521 column,
522 &message,
523 ))
524 })
525}
526
527fn deserialize_yaml<T>(path: &Path, raw: &str, sanitized: &str) -> Result<T>
528where
529 T: for<'de> serde::Deserialize<'de>,
530{
531 let deserializer = serde_yaml::Deserializer::from_str(sanitized);
532 serde_path_to_error::deserialize(deserializer).map_err(|error| {
533 let schema_path = error.path().to_string();
534 let inner = error.into_inner();
535 let (line, column) = inner
536 .location()
537 .map(|location| (location.line(), location.column()))
538 .unwrap_or((0, 0));
539 anyhow!(format_openapi_deserialize_error(
540 "YAML",
541 path,
542 raw,
543 if schema_path.is_empty() {
544 None
545 } else {
546 Some(schema_path.as_str())
547 },
548 line,
549 column,
550 &inner.to_string(),
551 ))
552 })
553}
554
555fn parse_json_openapi_document(path: &Path, raw: &str) -> Result<LoadedOpenApiDocument> {
556 let document = if detect_openapi_version(raw) == OpenApiVersion::Swagger2 {
557 OpenApiDocument::from(deserialize_json::<Swagger2Document>(path, raw)?)
558 } else {
559 OpenApiDocument::from(deserialize_json::<OpenApi3Document>(path, raw)?)
560 };
561 Ok(LoadedOpenApiDocument {
562 document,
563 source: OpenApiSource::new(SourceFormat::Json, raw.to_owned()),
564 })
565}
566
567fn parse_yaml_openapi_document(path: &Path, raw: &str) -> Result<LoadedOpenApiDocument> {
568 let sanitized = sanitize_yaml_for_parser(raw);
569 let document = if detect_openapi_version(raw) == OpenApiVersion::Swagger2 {
570 OpenApiDocument::from(deserialize_yaml::<Swagger2Document>(
571 path,
572 raw,
573 sanitized.as_ref(),
574 )?)
575 } else {
576 OpenApiDocument::from(deserialize_yaml::<OpenApi3Document>(
577 path,
578 raw,
579 sanitized.as_ref(),
580 )?)
581 };
582 Ok(LoadedOpenApiDocument {
583 document,
584 source: OpenApiSource::new(SourceFormat::Yaml, raw.to_owned()),
585 })
586}
587
588fn format_openapi_deserialize_error(
589 format_name: &str,
590 path: &Path,
591 raw: &str,
592 schema_path: Option<&str>,
593 line: usize,
594 column: usize,
595 message: &str,
596) -> String {
597 let mut rendered = format!(
598 "failed to parse {format_name} OpenAPI document `{}`",
599 path.display()
600 );
601 rendered.push_str("\nCaused by:");
602
603 if let Some(schema_path) = schema_path {
604 rendered.push_str(&format!(
605 "\n schema mismatch at `{schema_path}`: {message}"
606 ));
607 } else {
608 rendered.push_str(&format!("\n {message}"));
609 }
610
611 if line > 0 && column > 0 {
612 rendered.push_str(&format!("\n location: line {line}, column {column}"));
613 if let Some(source_line) = raw.lines().nth(line.saturating_sub(1)) {
614 rendered.push_str(&format!("\n source: {source_line}"));
615 rendered.push_str(&format!(
616 "\n {}^",
617 " ".repeat(column.saturating_sub(1))
618 ));
619 }
620 }
621
622 rendered.push_str(
623 "\n note: this usually means the document is valid JSON/YAML, but an OpenAPI field had an unexpected shape.",
624 );
625 rendered
626}
627
628fn sanitize_yaml_for_parser(raw: &str) -> Cow<'_, str> {
629 let needs_unicode_fix = raw.contains('\u{2028}') || raw.contains('\u{2029}');
641 let needs_c1_fix = raw.chars().any(|c| ('\u{0080}'..='\u{009F}').contains(&c));
642 if !raw.contains('\t') && !needs_unicode_fix && !needs_c1_fix {
643 return Cow::Borrowed(raw);
644 }
645
646 let mut changed = false;
647 let mut normalized = String::with_capacity(raw.len());
648
649 for segment in raw.split_inclusive('\n') {
650 let line = segment.strip_suffix('\n').unwrap_or(segment);
651 let has_newline = segment.len() != line.len();
652 if line.contains('\t') && line.chars().all(|ch| matches!(ch, ' ' | '\t')) {
653 changed = true;
654 } else {
655 let has_sep = line.contains('\u{2028}') || line.contains('\u{2029}');
657 let has_c1 = line.chars().any(|c| ('\u{0080}'..='\u{009F}').contains(&c));
658 if has_sep || has_c1 {
659 let fixed: String = line
660 .chars()
661 .map(|c| match c {
662 '\u{2028}' | '\u{2029}' => ' ',
664 c if ('\u{0080}'..='\u{009F}').contains(&c) => '\0',
666 c => c,
667 })
668 .filter(|&c| c != '\0')
669 .collect();
670 normalized.push_str(&fixed);
671 changed = true;
672 } else {
673 normalized.push_str(line);
674 }
675 }
676
677 if has_newline {
678 normalized.push('\n');
679 }
680 }
681
682 if changed {
683 Cow::Owned(normalized)
684 } else {
685 Cow::Borrowed(raw)
686 }
687}
688
689struct OpenApiImporter {
690 document: OpenApiDocument,
691 source: OpenApiSource,
692 models: BTreeMap<String, Model>,
693 generated_model_names: BTreeSet<String>,
694 generated_operation_names: BTreeSet<String>,
695 local_ref_model_names: BTreeMap<String, String>,
696 active_model_builds: BTreeSet<String>,
697 active_local_ref_imports: BTreeSet<String>,
698 normalized_all_of_refs: BTreeMap<String, Schema>,
699 active_all_of_refs: Vec<String>,
700 active_object_view_refs: Vec<String>,
701 warnings: Vec<OpenApiDiagnostic>,
702 options: LoadOpenApiOptions,
703}
704
705impl OpenApiImporter {
706 fn new(document: OpenApiDocument, source: OpenApiSource, options: LoadOpenApiOptions) -> Self {
707 Self {
708 document,
709 source,
710 models: BTreeMap::new(),
711 generated_model_names: BTreeSet::new(),
712 generated_operation_names: BTreeSet::new(),
713 local_ref_model_names: BTreeMap::new(),
714 active_model_builds: BTreeSet::new(),
715 active_local_ref_imports: BTreeSet::new(),
716 normalized_all_of_refs: BTreeMap::new(),
717 active_all_of_refs: Vec::new(),
718 active_object_view_refs: Vec::new(),
719 warnings: Vec::new(),
720 options,
721 }
722 }
723
724 fn build_ir(mut self) -> Result<OpenApiLoadResult> {
725 measure_openapi_phase(
726 self.options.emit_timings,
727 "openapi_component_models",
728 || self.import_component_models(),
729 )?;
730
731 let mut operations = Vec::new();
732 measure_openapi_phase(self.options.emit_timings, "openapi_operations", || {
733 let paths = self.document.paths.clone();
734 for (path, item) in &paths {
735 operations.extend(self.import_path_item(path, item)?);
736 }
737 Ok(())
738 })?;
739
740 let ir = CoreIr {
741 models: self.models.into_values().collect(),
742 operations,
743 ..Default::default()
744 };
745
746 measure_openapi_phase(self.options.emit_timings, "openapi_validate_ir", || {
747 validate_ir(&ir).map_err(|errors| {
748 let details = errors
749 .0
750 .iter()
751 .map(|issue| format!("{}: {}", issue.path, issue.message))
752 .collect::<Vec<_>>()
753 .join("\n");
754 anyhow!("generated IR is invalid:\n{details}")
755 })
756 })?;
757 Ok(OpenApiLoadResult {
758 ir,
759 warnings: self.warnings,
760 })
761 }
762
763 fn import_component_models(&mut self) -> Result<()> {
764 let mut schemas = Vec::new();
765 for (name, schema) in self.document.components.schemas.clone() {
766 let pointer = format!("#/components/schemas/{name}");
767 schemas.push((name, schema, pointer));
768 }
769 for (name, schema) in self.document.definitions.clone() {
770 let pointer = format!("#/definitions/{name}");
771 schemas.push((name, schema, pointer));
772 }
773 let total = schemas.len();
774 for (index, (name, schema, pointer)) in schemas.into_iter().enumerate() {
775 if self.options.emit_timings {
776 eprintln!(
777 "timing: starting component_model [{}/{}] {}",
778 index + 1,
779 total,
780 name
781 );
782 }
783 let started = Instant::now();
784 self.ensure_named_schema_model(&name, &schema, &pointer)?;
785 if self.options.emit_timings {
786 eprintln!(
787 "timing: component_model [{}/{}] {:<40} {}",
788 index + 1,
789 total,
790 name,
791 format_duration(started.elapsed())
792 );
793 }
794 }
795 Ok(())
796 }
797
798 fn import_path_item(&mut self, path: &str, item: &PathItem) -> Result<Vec<Operation>> {
799 let mut operations = Vec::new();
800 let shared_parameters = item.parameters.clone().unwrap_or_default();
801 let candidates = [
802 (HttpMethod::Get, item.get.as_ref()),
803 (HttpMethod::Post, item.post.as_ref()),
804 (HttpMethod::Put, item.put.as_ref()),
805 (HttpMethod::Patch, item.patch.as_ref()),
806 (HttpMethod::Delete, item.delete.as_ref()),
807 ];
808
809 for (method, spec) in candidates {
810 let Some(spec) = spec else {
811 continue;
812 };
813
814 let operation_name = self.reserve_operation_name(
815 spec.operation_id
816 .as_deref()
817 .map(str::trim)
818 .filter(|value| !value.is_empty())
819 .map(ToOwned::to_owned)
820 .unwrap_or_else(|| fallback_operation_name(method, path)),
821 );
822 let mut operation = Operation {
823 id: format!("operation.{operation_name}"),
824 name: operation_name.clone(),
825 method,
826 path: path.to_owned(),
827 params: Vec::new(),
828 request_body: None,
829 responses: Vec::new(),
830 attributes: operation_attributes(spec),
831 source: Some(SourceRef {
832 pointer: format!("#/paths/{}/{}", json_pointer_key(path), method_key(method)),
833 line: None,
834 }),
835 };
836 let mut unnamed_parameter_counter = 0usize;
837 let mut form_data_parameters = Vec::new();
838 let shared_len = shared_parameters.len();
839
840 for (param_idx, param) in shared_parameters.iter().chain(spec.parameters.iter()).enumerate() {
841 let mut resolved = self.resolve_parameter(param)?;
842 if resolved.name.trim().is_empty() {
843 unnamed_parameter_counter += 1;
844 let param_pointer = if param_idx < shared_len {
848 format!("#/paths/{}/parameters/{}", json_pointer_key(path), param_idx)
849 } else {
850 format!(
851 "#/paths/{}/{}/parameters/{}",
852 json_pointer_key(path),
853 method_key(method),
854 param_idx - shared_len,
855 )
856 };
857 self.handle_unhandled(
858 ¶m_pointer,
859 DiagnosticKind::EmptyParameterName {
860 counter: unnamed_parameter_counter,
861 },
862 )?;
863 resolved.name = format!(
864 "unnamed_{}_parameter_{}",
865 raw_parameter_location_label(resolved.location),
866 unnamed_parameter_counter
867 );
868 }
869 if resolved.location == RawParameterLocation::Body {
870 let request_body =
871 self.import_swagger_body_parameter(&resolved, spec, &operation_name)?;
872 if operation.request_body.is_some() {
873 bail!(self.make_diagnostic(
874 &format!("operation `{operation_name}`"),
875 DiagnosticKind::MultipleRequestBodyDeclarations {
876 note: "Arvalez can normalize either an OpenAPI `requestBody` or a single Swagger 2 `in: body` parameter for an operation.".into(),
877 },
878 ));
879 }
880 operation.request_body = Some(request_body);
881 continue;
882 }
883 if resolved.location == RawParameterLocation::FormData {
884 form_data_parameters.push(resolved);
885 continue;
886 }
887
888 operation.params.push(self.import_parameter(&resolved)?);
889 }
890
891 if !form_data_parameters.is_empty() {
892 if operation.request_body.is_some() {
893 bail!(self.make_diagnostic(
894 &format!("operation `{operation_name}`"),
895 DiagnosticKind::MultipleRequestBodyDeclarations {
896 note: "Arvalez can normalize either an OpenAPI `requestBody`, a single Swagger 2 `in: body` parameter, or Swagger 2 `formData` parameters for an operation.".into(),
897 },
898 ));
899 }
900 operation.request_body = Some(self.import_swagger_form_data_request_body(
901 &form_data_parameters,
902 spec,
903 &operation_name,
904 )?);
905 }
906
907 if let Some(request_body) = &spec.request_body {
908 if operation.request_body.is_some() {
909 bail!(self.make_diagnostic(
910 &format!("operation `{operation_name}`"),
911 DiagnosticKind::MultipleRequestBodyDeclarations {
912 note: "Arvalez can normalize either an OpenAPI `requestBody` or a single Swagger 2 `in: body` parameter for an operation.".into(),
913 },
914 ));
915 }
916 operation.request_body =
917 Some(self.import_request_body(request_body, &operation_name, path, method)?);
918 }
919
920 for (status, response_or_ref) in &spec.responses {
921 let response = self.resolve_response_spec(response_or_ref)?;
922 operation.responses.push(self.import_response(
923 status,
924 &response,
925 &operation_name,
926 path,
927 method,
928 )?);
929 }
930
931 operations.push(operation);
932 }
933
934 Ok(operations)
935 }
936
937 fn import_parameter(&mut self, param: &ParameterSpec) -> Result<Parameter> {
938 let schema = param.effective_schema().ok_or_else(|| {
939 anyhow::Error::new(self.make_diagnostic(
940 &format!("parameter `{}`", param.name),
941 DiagnosticKind::ParameterMissingSchema {
942 name: param.name.clone(),
943 },
944 ))
945 })?;
946 let imported = self.import_schema_type(
947 &schema,
948 &InlineModelContext::Parameter {
949 name: param.name.clone(),
950 },
951 )?;
952
953 Ok(Parameter {
954 name: param.name.clone(),
955 location: param.location.as_ir_location().ok_or_else(|| {
956 anyhow::Error::new(self.make_diagnostic(
957 &format!("parameter `{}`", param.name),
958 DiagnosticKind::UnsupportedParameterLocation {
959 name: param.name.clone(),
960 },
961 ))
962 })?,
963 type_ref: imported
964 .type_ref
965 .unwrap_or_else(|| TypeRef::primitive("any")),
966 required: param.required,
967 attributes: parameter_attributes(¶m, &schema),
968 })
969 }
970
971 fn import_swagger_body_parameter(
972 &mut self,
973 param: &ParameterSpec,
974 spec: &OperationSpec,
975 operation_name: &str,
976 ) -> Result<RequestBody> {
977 let schema = param.effective_schema().ok_or_else(|| {
978 anyhow::Error::new(self.make_diagnostic(
979 &format!("body parameter `{}`", param.name),
980 DiagnosticKind::BodyParameterMissingSchema {
981 name: param.name.clone(),
982 },
983 ))
984 })?;
985
986 let imported = self.import_schema_type(
987 &schema,
988 &InlineModelContext::RequestBody {
989 operation_name: operation_name.to_owned(),
990 pointer: format!(
991 "#/operations/{operation_name}/body_parameter/{}",
992 param.name
993 ),
994 },
995 )?;
996
997 let media_type = spec
998 .consumes
999 .first()
1000 .cloned()
1001 .or_else(|| self.document.consumes.first().cloned())
1002 .unwrap_or_else(|| "application/json".into());
1003
1004 let mut attributes = schema_runtime_attributes(&schema);
1005 if !param.description.trim().is_empty() {
1006 attributes.insert(
1007 "description".into(),
1008 Value::String(param.description.trim().to_owned()),
1009 );
1010 }
1011
1012 Ok(RequestBody {
1013 required: param.required,
1014 media_type,
1015 type_ref: imported.type_ref,
1016 attributes,
1017 })
1018 }
1019
1020 fn import_swagger_form_data_request_body(
1021 &mut self,
1022 params: &[ParameterSpec],
1023 spec: &OperationSpec,
1024 operation_name: &str,
1025 ) -> Result<RequestBody> {
1026 let mut properties = IndexMap::new();
1027 let mut required = Vec::new();
1028 for param in params {
1029 let mut schema = param.effective_schema().ok_or_else(|| {
1030 anyhow::Error::new(self.make_diagnostic(
1031 &format!("formData parameter `{}`", param.name),
1032 DiagnosticKind::FormDataParameterMissingSchema {
1033 name: param.name.clone(),
1034 },
1035 ))
1036 })?;
1037 if !param.description.trim().is_empty() {
1038 schema.extra_keywords.insert(
1039 "description".into(),
1040 Value::String(param.description.trim().to_owned()),
1041 );
1042 }
1043 if param.required {
1044 required.push(param.name.clone());
1045 }
1046 properties.insert(param.name.clone(), SchemaOrBool::Schema(schema));
1047 }
1048
1049 let imported = self.import_schema_type(
1050 &Schema {
1051 schema_type: Some(SchemaTypeDecl::Single("object".into())),
1052 properties: Some(properties),
1053 required: (!required.is_empty()).then_some(required.clone()),
1054 ..Schema::default()
1055 },
1056 &InlineModelContext::RequestBody {
1057 operation_name: operation_name.to_owned(),
1058 pointer: format!("#/operations/{operation_name}/formData"),
1059 },
1060 )?;
1061
1062 let media_type = spec
1063 .consumes
1064 .first()
1065 .cloned()
1066 .or_else(|| self.document.consumes.first().cloned())
1067 .unwrap_or_else(|| "application/x-www-form-urlencoded".into());
1068
1069 let mut attributes = Attributes::default();
1070 if params.iter().any(|param| param.required) {
1071 attributes.insert("form_encoding".into(), Value::String(media_type.clone()));
1072 }
1073
1074 Ok(RequestBody {
1075 required: params.iter().any(|param| param.required),
1076 media_type,
1077 type_ref: imported.type_ref,
1078 attributes,
1079 })
1080 }
1081
1082 fn resolve_parameter(&self, param: &ParameterOrRef) -> Result<ParameterSpec> {
1083 let mut seen = BTreeSet::new();
1084 self.resolve_parameter_inner(param, &mut seen)
1085 }
1086
1087 fn resolve_parameter_inner(
1088 &self,
1089 param: &ParameterOrRef,
1090 seen: &mut BTreeSet<String>,
1091 ) -> Result<ParameterSpec> {
1092 match param {
1093 ParameterOrRef::Inline(param) => Ok(param.clone()),
1094 ParameterOrRef::Ref { reference } => {
1095 if !seen.insert(reference.clone()) {
1096 bail!(self.make_pointer_diagnostic(
1097 reference,
1098 DiagnosticKind::RecursiveParameterCycle {
1099 reference: reference.to_owned()
1100 },
1101 ));
1102 }
1103
1104 if let Some(parameter) = self.resolve_named_parameter_reference(reference) {
1105 return self
1106 .resolve_parameter_inner(&ParameterOrRef::Inline(parameter.clone()), seen);
1107 }
1108
1109 if let Some(parameter) = self.resolve_path_parameter_reference(reference)? {
1110 return self.resolve_parameter_inner(parameter, seen);
1111 }
1112
1113 Err(anyhow!("unsupported reference `{reference}`"))
1114 }
1115 }
1116 }
1117
1118 fn resolve_named_parameter_reference(&self, reference: &str) -> Option<&ParameterSpec> {
1119 let name = ref_name(reference).ok()?;
1120 self.document
1121 .components
1122 .parameters
1123 .get(&name)
1124 .or_else(|| self.document.parameters.get(&name))
1125 }
1126
1127 fn resolve_path_parameter_reference<'a>(
1128 &'a self,
1129 reference: &str,
1130 ) -> Result<Option<&'a ParameterOrRef>> {
1131 let Some(pointer) = reference.strip_prefix("#/") else {
1132 return Ok(None);
1133 };
1134 let segments = pointer
1135 .split('/')
1136 .map(decode_json_pointer_segment)
1137 .collect::<Result<Vec<_>>>()?;
1138 if segments.first().map(String::as_str) != Some("paths") {
1139 return Ok(None);
1140 }
1141
1142 match segments.as_slice() {
1143 [_, path, scope, index] if scope == "parameters" => {
1144 let index = index.parse::<usize>().ok();
1145 let param = self
1146 .document
1147 .paths
1148 .get(path)
1149 .and_then(|item| item.parameters.as_ref())
1150 .and_then(|params| index.and_then(|idx| params.get(idx)));
1151 Ok(param)
1152 }
1153 [_, path, method, scope, index] if scope == "parameters" => {
1154 let index = index.parse::<usize>().ok();
1155 let param = self
1156 .document
1157 .paths
1158 .get(path)
1159 .and_then(|item| match method.as_str() {
1160 "get" => item.get.as_ref(),
1161 "post" => item.post.as_ref(),
1162 "put" => item.put.as_ref(),
1163 "patch" => item.patch.as_ref(),
1164 "delete" => item.delete.as_ref(),
1165 _ => None,
1166 })
1167 .and_then(|operation| index.and_then(|idx| operation.parameters.get(idx)));
1168 Ok(param)
1169 }
1170 _ => Ok(None),
1171 }
1172 }
1173
1174 fn import_request_body(
1175 &mut self,
1176 request_body: &RequestBodyOrRef,
1177 operation_name: &str,
1178 path: &str,
1179 method: HttpMethod,
1180 ) -> Result<RequestBody> {
1181 let fallback_pointer = format!(
1182 "#/paths/{}/{}/requestBody",
1183 json_pointer_key(path),
1184 method_key(method)
1185 );
1186 let (request_body, pointer) = self.resolve_request_body(request_body, &fallback_pointer)?;
1187 let content_pointer = format!("{pointer}/content");
1188 let Some((media_type, media_spec)) = request_body.content.iter().next() else {
1189 self.warnings.push(self.make_pointer_diagnostic(
1190 &content_pointer,
1191 DiagnosticKind::EmptyRequestBodyContent,
1192 ));
1193 return Ok(RequestBody {
1194 required: request_body.required,
1195 media_type: "application/octet-stream".into(),
1196 type_ref: None,
1197 attributes: Attributes::default(),
1198 });
1199 };
1200
1201 let imported = media_spec
1202 .schema
1203 .as_ref()
1204 .map(|schema| {
1205 self.import_schema_type(
1206 schema,
1207 &InlineModelContext::RequestBody {
1208 operation_name: operation_name.to_owned(),
1209 pointer: format!(
1210 "{content_pointer}/{}/schema",
1211 json_pointer_key(media_type)
1212 ),
1213 },
1214 )
1215 })
1216 .transpose()?;
1217
1218 Ok(RequestBody {
1219 required: request_body.required,
1220 media_type: media_type.clone(),
1221 type_ref: imported.and_then(|value| value.type_ref),
1222 attributes: media_spec
1223 .schema
1224 .as_ref()
1225 .map(schema_runtime_attributes)
1226 .unwrap_or_default(),
1227 })
1228 }
1229
1230 fn resolve_request_body(
1231 &self,
1232 request_body: &RequestBodyOrRef,
1233 pointer: &str,
1234 ) -> Result<(RequestBodySpec, String)> {
1235 let mut seen = BTreeSet::new();
1236 self.resolve_request_body_inner(request_body, pointer, &mut seen)
1237 }
1238
1239 fn resolve_request_body_inner(
1240 &self,
1241 request_body: &RequestBodyOrRef,
1242 pointer: &str,
1243 seen: &mut BTreeSet<String>,
1244 ) -> Result<(RequestBodySpec, String)> {
1245 match request_body {
1246 RequestBodyOrRef::Inline(spec) => Ok((spec.clone(), pointer.to_owned())),
1247 RequestBodyOrRef::Ref { reference } => {
1248 if !seen.insert(reference.clone()) {
1249 bail!(self.make_pointer_diagnostic(
1250 reference,
1251 DiagnosticKind::RecursiveRequestBodyCycle {
1252 reference: reference.to_owned()
1253 },
1254 ));
1255 }
1256 let name = ref_name(reference)?;
1257 let referenced = self
1258 .document
1259 .components
1260 .request_bodies
1261 .get(&name)
1262 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
1263 self.resolve_request_body_inner(referenced, reference, seen)
1264 }
1265 }
1266 }
1267
1268 fn resolve_response_spec(&self, response: &ResponseSpecOrRef) -> Result<ResponseSpec> {
1269 match &response.reference {
1270 None => Ok(ResponseSpec {
1271 description: response.description.clone(),
1272 content: response.content.clone(),
1273 }),
1274 Some(reference) => {
1275 let Some(pointer) = reference.strip_prefix("#/") else {
1276 return Err(anyhow!("unsupported reference `{reference}`"));
1277 };
1278 let segments: Vec<&str> = pointer.split('/').collect();
1279 match segments.as_slice() {
1280 ["components", "responses", name] => self
1282 .document
1283 .components
1284 .responses
1285 .get(*name)
1286 .cloned()
1287 .ok_or_else(|| anyhow!("unsupported reference `{reference}`")),
1288 ["responses", name] => self
1290 .document
1291 .responses
1292 .get(*name)
1293 .cloned()
1294 .ok_or_else(|| anyhow!("unsupported reference `{reference}`")),
1295 _ => Ok(ResponseSpec::default()),
1299 }
1300 }
1301 }
1302 }
1303
1304 fn import_response(
1305 &mut self,
1306 status: &str,
1307 response: &ResponseSpec,
1308 operation_name: &str,
1309 path: &str,
1310 method: HttpMethod,
1311 ) -> Result<Response> {
1312 let (media_type, schema) = response
1313 .content
1314 .iter()
1315 .find_map(|(media_type, media)| {
1316 media.schema.as_ref().map(|schema| (media_type, schema))
1317 })
1318 .map(|(media_type, schema)| (Some(media_type.clone()), Some(schema)))
1319 .unwrap_or((None, None));
1320
1321 let imported = schema
1322 .map(|schema| {
1323 self.import_schema_type(
1324 schema,
1325 &InlineModelContext::Response {
1326 operation_name: operation_name.to_owned(),
1327 status: status.to_owned(),
1328 pointer: media_type.as_ref().map_or_else(
1329 || {
1330 format!(
1331 "#/paths/{}/{}/responses/{}",
1332 json_pointer_key(path),
1333 method_key(method),
1334 json_pointer_key(status)
1335 )
1336 },
1337 |media_type| {
1338 format!(
1339 "#/paths/{}/{}/responses/{}/content/{}/schema",
1340 json_pointer_key(path),
1341 method_key(method),
1342 json_pointer_key(status),
1343 json_pointer_key(media_type)
1344 )
1345 },
1346 ),
1347 },
1348 )
1349 })
1350 .transpose()?;
1351
1352 let mut attributes = Attributes::default();
1353 if !response.description.is_empty() {
1354 attributes.insert(
1355 "description".into(),
1356 Value::String(response.description.clone()),
1357 );
1358 }
1359 if let Some(schema) = schema {
1360 attributes.extend(schema_runtime_attributes(schema));
1361 }
1362
1363 Ok(Response {
1364 status: status.to_owned(),
1365 media_type,
1366 type_ref: imported.and_then(|value| value.type_ref),
1367 attributes,
1368 })
1369 }
1370
1371 fn ensure_named_schema_model(
1372 &mut self,
1373 name: &str,
1374 schema: &Schema,
1375 pointer: &str,
1376 ) -> Result<()> {
1377 if self.models.contains_key(name) {
1378 return Ok(());
1379 }
1380
1381 let model = self.build_model_from_schema(name, schema, pointer)?;
1382 self.generated_model_names.insert(name.to_owned());
1383 self.models.insert(name.to_owned(), model);
1384 Ok(())
1385 }
1386
1387 fn build_model_from_schema(
1388 &mut self,
1389 name: &str,
1390 schema: &Schema,
1391 pointer: &str,
1392 ) -> Result<Model> {
1393 if schema.all_of.is_some() && schema_is_object_like(schema) {
1394 return self.build_object_model_from_all_of(name, schema, pointer);
1395 }
1396
1397 let schema = self.normalize_schema(schema, pointer)?;
1398 let schema = schema.as_ref();
1399 self.validate_schema_keywords(schema, pointer)?;
1400
1401 if let Some(enum_values) = &schema.enum_values {
1402 let mut model = Model::new(format!("model.{}", to_snake_case(name)), name.to_owned());
1403 model.source = Some(SourceRef {
1404 pointer: pointer.to_owned(),
1405 line: None,
1406 });
1407 model
1408 .attributes
1409 .insert("enum_values".into(), Value::Array(enum_values.clone()));
1410 if let Some(schema_type) = schema.primary_schema_type() {
1411 model.attributes.insert(
1412 "enum_base_type".into(),
1413 Value::String(schema_type.to_owned()),
1414 );
1415 }
1416 if let Some(title) = &schema.title {
1417 model
1418 .attributes
1419 .insert("title".into(), Value::String(title.clone()));
1420 }
1421 return Ok(model);
1422 }
1423
1424 if !schema_is_object_like(schema) {
1425 let imported = self.import_schema_type_normalized(
1426 schema,
1427 &InlineModelContext::NamedSchema {
1428 name: name.to_owned(),
1429 pointer: pointer.to_owned(),
1430 },
1431 true,
1432 None,
1433 )?;
1434 let mut model = Model::new(format!("model.{}", to_snake_case(name)), name.to_owned());
1435 model.source = Some(SourceRef {
1436 pointer: pointer.to_owned(),
1437 line: None,
1438 });
1439 model.attributes.insert(
1440 "alias_type_ref".into(),
1441 json!(
1442 imported
1443 .type_ref
1444 .unwrap_or_else(|| TypeRef::primitive("any"))
1445 ),
1446 );
1447 model
1448 .attributes
1449 .insert("alias_nullable".into(), Value::Bool(imported.nullable));
1450 model.attributes.extend(schema_runtime_attributes(schema));
1451 if let Some(title) = &schema.title {
1452 model
1453 .attributes
1454 .insert("title".into(), Value::String(title.clone()));
1455 }
1456 return Ok(model);
1457 }
1458
1459 let empty_properties = IndexMap::new();
1460 let properties = schema.properties.as_ref().unwrap_or(&empty_properties);
1461 let required: BTreeSet<&str> = schema
1462 .required
1463 .iter()
1464 .flat_map(|items| items.iter().map(String::as_str))
1465 .collect();
1466
1467 let mut model = Model::new(format!("model.{}", to_snake_case(name)), name.to_owned());
1468 model.source = Some(SourceRef {
1469 pointer: pointer.to_owned(),
1470 line: None,
1471 });
1472 if let Some(title) = &schema.title {
1473 model
1474 .attributes
1475 .insert("title".into(), Value::String(title.clone()));
1476 }
1477
1478 let mut unnamed_field_counter = 0usize;
1479 for (field_name, property_schema_or_bool) in properties {
1480 let Some(property_schema) = property_schema_or_bool.as_schema() else {
1482 continue;
1483 };
1484 let original_field_name = field_name.clone();
1485 let field_name = self.normalize_field_name(
1486 field_name.clone(),
1487 &format!("{pointer}/properties"),
1488 &mut unnamed_field_counter,
1489 )?;
1490 let imported = self.import_schema_type(
1491 property_schema,
1492 &InlineModelContext::Field {
1493 model_name: name.to_owned(),
1494 field_name: original_field_name.clone(),
1495 pointer: format!(
1496 "{}/properties/{}",
1497 pointer,
1498 json_pointer_key(&original_field_name)
1499 ),
1500 },
1501 )?;
1502 let mut field = Field::new(
1503 field_name.clone(),
1504 imported
1505 .type_ref
1506 .unwrap_or_else(|| TypeRef::primitive("any")),
1507 );
1508 field.optional = !required.contains(original_field_name.as_str());
1509 field.nullable = imported.nullable;
1510 field.attributes = schema_runtime_attributes(property_schema);
1511 model.fields.push(field);
1512 }
1513
1514 Ok(model)
1515 }
1516
1517 fn build_object_model_from_all_of(
1518 &mut self,
1519 name: &str,
1520 schema: &Schema,
1521 pointer: &str,
1522 ) -> Result<Model> {
1523 let view = self.collect_object_schema_view(schema, pointer)?;
1524 let required = view.required;
1525
1526 let mut model = Model::new(format!("model.{}", to_snake_case(name)), name.to_owned());
1527 model.source = Some(SourceRef {
1528 pointer: pointer.to_owned(),
1529 line: None,
1530 });
1531 if let Some(title) = view.title {
1532 model
1533 .attributes
1534 .insert("title".into(), Value::String(title));
1535 }
1536
1537 let mut unnamed_field_counter = 0usize;
1538 for (field_name, property_schema) in view.properties {
1539 let original_field_name = field_name.clone();
1540 let field_name = self.normalize_field_name(
1541 field_name,
1542 &format!("{pointer}/properties"),
1543 &mut unnamed_field_counter,
1544 )?;
1545 let imported = self.import_schema_type(
1546 &property_schema,
1547 &InlineModelContext::Field {
1548 model_name: name.to_owned(),
1549 field_name: original_field_name.clone(),
1550 pointer: format!(
1551 "{}/properties/{}",
1552 pointer,
1553 json_pointer_key(&original_field_name)
1554 ),
1555 },
1556 )?;
1557 let mut field = Field::new(
1558 field_name.clone(),
1559 imported
1560 .type_ref
1561 .unwrap_or_else(|| TypeRef::primitive("any")),
1562 );
1563 field.optional = !required.contains(original_field_name.as_str());
1564 field.nullable = imported.nullable;
1565 field.attributes = schema_runtime_attributes(&property_schema);
1566 model.fields.push(field);
1567 }
1568
1569 Ok(model)
1570 }
1571
1572 fn import_schema_type(
1573 &mut self,
1574 schema: &Schema,
1575 context: &InlineModelContext,
1576 ) -> Result<ImportedType> {
1577 self.import_schema_type_inner(schema, context, false)
1578 }
1579
1580 fn import_schema_type_inner(
1581 &mut self,
1582 schema: &Schema,
1583 context: &InlineModelContext,
1584 skip_keyword_validation: bool,
1585 ) -> Result<ImportedType> {
1586 let local_reference = schema
1587 .reference
1588 .as_deref()
1589 .filter(|reference| is_inline_local_schema_reference(reference))
1590 .map(ToOwned::to_owned);
1591 if let Some(imported) = self.import_decorated_reference_type(schema, context)? {
1592 return Ok(imported);
1593 }
1594 let schema = self.normalize_schema(schema, &context.describe())?;
1595 self.import_schema_type_normalized(
1596 schema.as_ref(),
1597 context,
1598 skip_keyword_validation,
1599 local_reference.as_deref(),
1600 )
1601 }
1602
1603 fn import_decorated_reference_type(
1604 &mut self,
1605 schema: &Schema,
1606 context: &InlineModelContext,
1607 ) -> Result<Option<ImportedType>> {
1608 if matches!(context, InlineModelContext::NamedSchema { .. }) {
1609 return Ok(None);
1610 }
1611
1612 let all_of = match &schema.all_of {
1613 Some(all_of) => all_of,
1614 None => return Ok(None),
1615 };
1616
1617 if schema_has_non_all_of_shape(schema) {
1618 return Ok(None);
1619 }
1620
1621 let mut reference: Option<&str> = None;
1622 for member in all_of {
1623 if let Some(member_ref) = member.reference.as_deref() {
1624 if reference.replace(member_ref).is_some() {
1625 return Ok(None);
1626 }
1627 continue;
1628 }
1629
1630 if !is_unconstrained_schema(member) {
1631 return Ok(None);
1632 }
1633 }
1634
1635 let Some(reference) = reference else {
1636 return Ok(None);
1637 };
1638
1639 Ok(Some(ImportedType {
1640 type_ref: Some(TypeRef::named(ref_name(reference)?)),
1641 nullable: false,
1642 }))
1643 }
1644
1645 fn import_schema_type_normalized(
1646 &mut self,
1647 schema: &Schema,
1648 context: &InlineModelContext,
1649 skip_keyword_validation: bool,
1650 local_reference: Option<&str>,
1651 ) -> Result<ImportedType> {
1652 if !skip_keyword_validation {
1653 self.validate_schema_keywords(schema, &context.describe())?;
1654 }
1655
1656 if let Some(reference) = &schema.reference {
1657 if is_inline_local_schema_reference(reference) {
1658 if self.active_local_ref_imports.contains(reference) {
1659 let model_name = self
1660 .local_ref_model_names
1661 .get(reference)
1662 .cloned()
1663 .unwrap_or_else(|| {
1664 to_pascal_case(
1665 &ref_name(reference).unwrap_or_else(|_| "RecursiveModel".into()),
1666 )
1667 });
1668 return Ok(ImportedType::plain(TypeRef::named(model_name)));
1669 }
1670
1671 self.active_local_ref_imports.insert(reference.clone());
1672 let resolved =
1677 self.resolve_schema_reference_for_all_of(reference, &context.describe())?;
1678 if schema_is_object_like(&resolved) {
1679 let already_registered = self.local_ref_model_names.contains_key(reference);
1680 if !already_registered {
1681 let model_name = self.inline_model_name(&resolved, context);
1682 self.local_ref_model_names
1683 .insert(reference.clone(), model_name);
1684 } else {
1685 let model_name = self.local_ref_model_names[reference].clone();
1690 self.active_local_ref_imports.remove(reference);
1691 return Ok(ImportedType::plain(TypeRef::named(model_name)));
1692 }
1693 }
1694 let result = self.import_schema_type_normalized(
1695 &resolved,
1696 context,
1697 skip_keyword_validation,
1698 Some(reference),
1699 );
1700 self.active_local_ref_imports.remove(reference);
1701 return result;
1702 }
1703 return Ok(ImportedType {
1704 type_ref: Some(TypeRef::named(ref_name(reference)?)),
1705 nullable: false,
1706 });
1707 }
1708
1709 if let Some(const_value) = &schema.const_value {
1710 return self.import_const_type(&schema, const_value, context);
1711 }
1712
1713 if schema_is_object_like(schema)
1714 && schema
1715 .any_of
1716 .as_ref()
1717 .is_some_and(|variants| variants.iter().all(is_validation_only_schema_variant))
1718 {
1719 return self.import_object_type(schema, context, local_reference);
1720 }
1721
1722 if let Some(any_of) = &schema.any_of {
1723 return self.import_any_of(any_of, context);
1724 }
1725
1726 if schema_is_object_like(schema)
1727 && schema
1728 .one_of
1729 .as_ref()
1730 .is_some_and(|variants| variants.iter().all(is_validation_only_schema_variant))
1731 {
1732 return self.import_object_type(schema, context, local_reference);
1733 }
1734
1735 if let Some(one_of) = &schema.one_of {
1736 return self.import_any_of(one_of, context);
1737 }
1738
1739 if let Some(imported) = self.import_implicit_schema_type(schema, context)? {
1740 return Ok(imported);
1741 }
1742
1743 if let Some(imported) = self.import_schema_type_from_decl(&schema, context)? {
1744 return Ok(imported);
1745 }
1746
1747 if is_unconstrained_schema(&schema) {
1748 return Ok(ImportedType::plain(TypeRef::primitive("any")));
1749 }
1750
1751 if schema.properties.is_some() || schema.additional_properties.is_some() {
1752 return self.import_object_type(&schema, context, local_reference);
1753 }
1754
1755 self.handle_unhandled(&context.describe(), DiagnosticKind::UnsupportedSchemaShape)?;
1756 Ok(ImportedType::plain(TypeRef::primitive("any")))
1757 }
1758
1759 fn import_schema_type_from_decl(
1760 &mut self,
1761 schema: &Schema,
1762 context: &InlineModelContext,
1763 ) -> Result<Option<ImportedType>> {
1764 let Some(schema_types) = &schema.schema_type else {
1765 return Ok(None);
1766 };
1767
1768 if let Some(embedded) = schema_types.embedded_schema() {
1769 return Ok(Some(self.import_schema_type(embedded, context)?));
1770 }
1771
1772 let variants = schema_types.as_slice();
1773 if variants.len() == 1 {
1774 let schema_type = variants[0].as_str();
1775 return Ok(Some(match schema_type {
1776 "string" => {
1777 if schema.format.as_deref() == Some("binary") {
1778 ImportedType::plain(TypeRef::primitive("binary"))
1779 } else {
1780 ImportedType::plain(TypeRef::primitive("string"))
1781 }
1782 }
1783 "integer" => ImportedType::plain(TypeRef::primitive("integer")),
1784 "number" => ImportedType::plain(TypeRef::primitive("number")),
1785 "boolean" => ImportedType::plain(TypeRef::primitive("boolean")),
1786 "array" => {
1787 match schema.items.as_ref() {
1788 Some(item_schema) => {
1789 let imported = self.import_schema_type(item_schema, context)?;
1790 ImportedType::plain(TypeRef::array(
1791 imported
1792 .type_ref
1793 .unwrap_or_else(|| TypeRef::primitive("any")),
1794 ))
1795 }
1796 None => ImportedType::plain(TypeRef::array(TypeRef::primitive("any"))),
1798 }
1799 }
1800 "object" => self.import_object_type(schema, context, None)?,
1801 "file" => ImportedType::plain(TypeRef::primitive("binary")),
1802 "null" => ImportedType {
1803 type_ref: Some(TypeRef::primitive("any")),
1804 nullable: true,
1805 },
1806 other => {
1807 self.handle_unhandled(
1808 &context.describe(),
1809 DiagnosticKind::UnsupportedSchemaType {
1810 schema_type: other.to_owned(),
1811 },
1812 )?;
1813 ImportedType::plain(TypeRef::primitive("any"))
1814 }
1815 }));
1816 }
1817
1818 let mut nullable = false;
1819 let mut type_refs = Vec::new();
1820 for schema_type in variants {
1821 match schema_type.as_str() {
1822 "null" => nullable = true,
1823 other => {
1824 let mut synthetic = schema.clone();
1825 synthetic.schema_type = Some(SchemaTypeDecl::Single(other.to_owned()));
1826 let imported = self
1827 .import_schema_type_from_decl(&synthetic, context)?
1828 .expect("single schema type should import");
1829 if imported.nullable {
1830 nullable = true;
1831 }
1832 if let Some(type_ref) = imported.type_ref {
1833 type_refs.push(type_ref);
1834 }
1835 }
1836 }
1837 }
1838
1839 let type_refs = dedupe_variants(type_refs);
1840 let type_ref = match type_refs.len() {
1841 0 => Some(TypeRef::primitive("any")),
1842 1 => type_refs.into_iter().next(),
1843 _ => Some(TypeRef::Union {
1844 variants: type_refs,
1845 }),
1846 };
1847
1848 Ok(Some(ImportedType { type_ref, nullable }))
1849 }
1850
1851 fn import_implicit_schema_type(
1852 &mut self,
1853 schema: &Schema,
1854 context: &InlineModelContext,
1855 ) -> Result<Option<ImportedType>> {
1856 if let Some(enum_values) = &schema.enum_values {
1857 let inferred = infer_enum_type(enum_values, schema.format.as_deref());
1858 return Ok(Some(ImportedType {
1859 type_ref: Some(inferred),
1860 nullable: false,
1861 }));
1862 }
1863
1864 if schema.items.is_some() {
1865 let item_schema = schema.items.as_ref().expect("checked is_some");
1866 let imported = self.import_schema_type(item_schema, context)?;
1867 return Ok(Some(ImportedType::plain(TypeRef::array(
1868 imported
1869 .type_ref
1870 .unwrap_or_else(|| TypeRef::primitive("any")),
1871 ))));
1872 }
1873
1874 if let Some(type_ref) = infer_format_only_type(schema.format.as_deref()) {
1875 return Ok(Some(ImportedType::plain(type_ref)));
1876 }
1877
1878 if schema.format.is_some() {
1882 return Ok(Some(ImportedType::plain(TypeRef::primitive("any"))));
1883 }
1884
1885 Ok(None)
1886 }
1887
1888 fn validate_schema_keywords(&mut self, schema: &Schema, context: &str) -> Result<()> {
1889 for keyword in schema.extra_keywords.keys() {
1890 if is_known_ignored_schema_keyword(keyword) || keyword.starts_with("x-") {
1891 continue;
1892 }
1893
1894 if is_known_but_unimplemented_schema_keyword(keyword) {
1895 self.handle_unhandled(
1896 context,
1897 DiagnosticKind::UnsupportedSchemaKeyword {
1898 keyword: keyword.clone(),
1899 },
1900 )?;
1901 continue;
1902 }
1903
1904 self.handle_unhandled(
1905 context,
1906 DiagnosticKind::UnknownSchemaKeyword {
1907 keyword: keyword.clone(),
1908 },
1909 )?;
1910 }
1911
1912 Ok(())
1913 }
1914
1915 fn normalize_schema<'a>(
1916 &mut self,
1917 schema: &'a Schema,
1918 context: &str,
1919 ) -> Result<Cow<'a, Schema>> {
1920 if schema.all_of.is_none() {
1921 return Ok(Cow::Borrowed(schema));
1922 }
1923
1924 let normalized = self.expand_all_of_schema(schema, context)?;
1925 Ok(Cow::Owned(normalized))
1926 }
1927
1928 fn expand_all_of_schema(&mut self, schema: &Schema, context: &str) -> Result<Schema> {
1929 let mut merged = Schema {
1930 all_of: None,
1931 ..schema.clone()
1932 };
1933
1934 for member in schema.all_of.clone().unwrap_or_default() {
1935 let resolved_member = self.resolve_schema_for_merge(&member, context)?;
1936 merged = self.merge_schemas(merged, resolved_member, context)?;
1937 }
1938
1939 Ok(merged)
1940 }
1941
1942 fn resolve_schema_for_merge(&mut self, schema: &Schema, context: &str) -> Result<Schema> {
1943 let mut resolved = if let Some(reference) = &schema.reference {
1944 self.resolve_schema_reference_for_all_of(reference, context)?
1945 } else {
1946 schema.clone()
1947 };
1948
1949 if resolved.all_of.is_some() {
1950 resolved = self.expand_all_of_schema(&resolved, context)?;
1951 }
1952
1953 if schema.reference.is_some() {
1954 let mut overlay = schema.clone();
1955 overlay.reference = None;
1956 overlay.all_of = None;
1957 resolved = self.merge_schemas(resolved, overlay, context)?;
1958 }
1959
1960 Ok(resolved)
1961 }
1962
1963 fn resolve_schema_reference_for_all_of(
1964 &mut self,
1965 reference: &str,
1966 context: &str,
1967 ) -> Result<Schema> {
1968 if let Some(cached) = self.normalized_all_of_refs.get(reference) {
1969 return Ok(cached.clone());
1970 }
1971
1972 if self.active_all_of_refs.iter().any(|item| item == reference) {
1973 self.handle_unhandled(
1974 context,
1975 DiagnosticKind::AllOfRecursiveCycle {
1976 reference: reference.to_owned(),
1977 },
1978 )?;
1979 return Ok(Schema::default());
1980 }
1981
1982 self.active_all_of_refs.push(reference.to_owned());
1983 let result: Result<Schema> = (|| {
1984 let mut resolved = self.resolve_schema_reference(reference)?;
1985 if resolved.all_of.is_some() {
1986 resolved = self.expand_all_of_schema(&resolved, reference)?;
1987 }
1988 Ok(resolved)
1989 })();
1990 self.active_all_of_refs.pop();
1991
1992 let resolved = result?;
1993 self.normalized_all_of_refs
1994 .insert(reference.to_owned(), resolved.clone());
1995 Ok(resolved)
1996 }
1997
1998 fn resolve_schema_reference(&self, reference: &str) -> Result<Schema> {
1999 let Some(pointer) = reference.strip_prefix("#/") else {
2000 bail!("unsupported reference `{reference}`");
2001 };
2002 let segments = pointer
2003 .split('/')
2004 .map(decode_json_pointer_segment)
2005 .collect::<Result<Vec<_>>>()?;
2006 enum ResolvedSchemaRef<'a> {
2007 Borrowed(&'a Schema),
2008 Owned(Schema),
2009 }
2010
2011 let (resolved, remainder): (ResolvedSchemaRef<'_>, &[String]) = match segments.as_slice() {
2012 [root, collection, name, rest @ ..]
2013 if root == "components" && collection == "schemas" =>
2014 {
2015 (
2016 ResolvedSchemaRef::Borrowed(
2017 self.document
2018 .components
2019 .schemas
2020 .get(name)
2021 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?,
2022 ),
2023 rest,
2024 )
2025 }
2026 [root, name, rest @ ..] if root == "definitions" => (
2027 ResolvedSchemaRef::Borrowed(
2028 self.document
2029 .definitions
2030 .get(name)
2031 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?,
2032 ),
2033 rest,
2034 ),
2035 [root, collection, name, schema_segment, rest @ ..]
2036 if root == "components"
2037 && collection == "parameters"
2038 && schema_segment == "schema" =>
2039 {
2040 (
2041 ResolvedSchemaRef::Owned(
2042 self.document
2043 .components
2044 .parameters
2045 .get(name)
2046 .and_then(ParameterSpec::effective_schema)
2047 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?,
2048 ),
2049 rest,
2050 )
2051 }
2052 [root, name, schema_segment, rest @ ..]
2053 if root == "parameters" && schema_segment == "schema" =>
2054 {
2055 (
2056 ResolvedSchemaRef::Owned(
2057 self.document
2058 .parameters
2059 .get(name)
2060 .and_then(ParameterSpec::effective_schema)
2061 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?,
2062 ),
2063 rest,
2064 )
2065 }
2066 [root, collection, name, rest @ ..]
2068 if root == "components" && collection == "responses" =>
2069 {
2070 let response = self
2071 .document
2072 .components
2073 .responses
2074 .get(name)
2075 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2076 return resolve_response_schema_reference(response, rest, reference);
2079 }
2080 [root, path, method, responses_key, status, rest @ ..]
2083 if root == "paths" && responses_key == "responses" =>
2084 {
2085 let operation = self
2086 .document
2087 .paths
2088 .get(path)
2089 .and_then(|item| match method.as_str() {
2090 "get" => item.get.as_ref(),
2091 "post" => item.post.as_ref(),
2092 "put" => item.put.as_ref(),
2093 "patch" => item.patch.as_ref(),
2094 "delete" => item.delete.as_ref(),
2095 _ => None,
2096 })
2097 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2098 let response_or_ref = operation
2099 .responses
2100 .get(status)
2101 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2102 let response = self.resolve_response_spec(response_or_ref)?;
2103 return resolve_response_schema_reference(&response, rest, reference);
2104 }
2105 [
2107 root,
2108 path,
2109 method,
2110 rb_key,
2111 content_key,
2112 media_type,
2113 schema_key,
2114 rest @ ..,
2115 ] if root == "paths"
2116 && rb_key == "requestBody"
2117 && content_key == "content"
2118 && schema_key == "schema" =>
2119 {
2120 let operation = self
2121 .document
2122 .paths
2123 .get(path)
2124 .and_then(|item| match method.as_str() {
2125 "get" => item.get.as_ref(),
2126 "post" => item.post.as_ref(),
2127 "put" => item.put.as_ref(),
2128 "patch" => item.patch.as_ref(),
2129 "delete" => item.delete.as_ref(),
2130 _ => None,
2131 })
2132 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2133 let request_body = match operation.request_body.as_ref() {
2134 Some(RequestBodyOrRef::Inline(rb)) => rb,
2135 _ => bail!("unsupported reference `{reference}`"),
2136 };
2137 let schema = request_body
2138 .content
2139 .get(media_type.as_str())
2140 .and_then(|m| m.schema.as_ref())
2141 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2142 return resolve_nested_schema_reference(schema, rest, reference);
2143 }
2144 [
2146 root,
2147 path,
2148 method,
2149 params_key,
2150 index_str,
2151 schema_key,
2152 rest @ ..,
2153 ] if root == "paths" && params_key == "parameters" && schema_key == "schema" => {
2154 let idx: usize = index_str
2155 .parse()
2156 .map_err(|_| anyhow!("unsupported reference `{reference}`"))?;
2157 let path_item = self
2158 .document
2159 .paths
2160 .get(path)
2161 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2162 let operation = match method.as_str() {
2163 "get" => path_item.get.as_ref(),
2164 "post" => path_item.post.as_ref(),
2165 "put" => path_item.put.as_ref(),
2166 "patch" => path_item.patch.as_ref(),
2167 "delete" => path_item.delete.as_ref(),
2168 _ => None,
2169 }
2170 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2171 let param_spec = operation
2172 .parameters
2173 .get(idx)
2174 .or_else(|| {
2175 path_item
2176 .parameters
2177 .as_ref()
2178 .and_then(|params| params.get(idx))
2179 })
2180 .and_then(|p| match p {
2181 ParameterOrRef::Inline(spec) => Some(spec),
2182 _ => None,
2183 })
2184 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2185 let schema = param_spec
2186 .effective_schema()
2187 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2188 return resolve_nested_schema_reference(&schema, rest, reference);
2189 }
2190 _ => bail!("unsupported reference `{reference}`"),
2191 };
2192
2193 let schema = match &resolved {
2194 ResolvedSchemaRef::Borrowed(schema) => *schema,
2195 ResolvedSchemaRef::Owned(schema) => schema,
2196 };
2197 resolve_nested_schema_reference(schema, remainder, reference)
2198 }
2199
2200 fn reserve_operation_name(&mut self, base: String) -> String {
2201 if self.generated_operation_names.insert(base.clone()) {
2202 return base;
2203 }
2204
2205 let mut counter = 2usize;
2206 loop {
2207 let candidate = format!("{base}_{counter}");
2208 if self.generated_operation_names.insert(candidate.clone()) {
2209 return candidate;
2210 }
2211 counter += 1;
2212 }
2213 }
2214
2215 fn normalize_field_name(
2216 &mut self,
2217 field_name: String,
2218 context: &str,
2219 unnamed_field_counter: &mut usize,
2220 ) -> Result<String> {
2221 if !field_name.trim().is_empty() {
2222 return Ok(field_name);
2223 }
2224
2225 *unnamed_field_counter += 1;
2226 let specific_pointer = format!("{}/{}", context, json_pointer_key(&field_name));
2230 self.handle_unhandled(
2231 &specific_pointer,
2232 DiagnosticKind::EmptyPropertyKey {
2233 counter: *unnamed_field_counter,
2234 },
2235 )?;
2236 Ok(format!("unnamed_field_{}", unnamed_field_counter))
2237 }
2238
2239 fn merge_schemas(
2240 &mut self,
2241 mut base: Schema,
2242 overlay: Schema,
2243 context: &str,
2244 ) -> Result<Schema> {
2245 let inferred_base_type = infer_schema_type_for_merge(&base);
2246 let inferred_overlay_type = infer_schema_type_for_merge(&overlay);
2247 let base_is_generic_object_placeholder = is_generic_object_placeholder(&base);
2248 let overlay_is_generic_object_placeholder = is_generic_object_placeholder(&overlay);
2249 let base_schema_type = base.schema_type.take();
2250 let overlay_schema_type = overlay.schema_type.clone();
2251 merge_non_codegen_optional_field(&mut base.definitions, overlay.definitions);
2252 merge_non_codegen_optional_field(&mut base.title, overlay.title);
2253 merge_non_codegen_optional_field(&mut base.format, overlay.format);
2254 base.schema_type = merge_schema_types(
2255 inferred_base_type,
2256 inferred_overlay_type,
2257 base_is_generic_object_placeholder,
2258 overlay_is_generic_object_placeholder,
2259 base_schema_type,
2260 overlay_schema_type,
2261 context,
2262 self,
2263 )?;
2264 merge_optional_field(
2265 &mut base.const_value,
2266 overlay.const_value,
2267 "const",
2268 context,
2269 self,
2270 )?;
2271 merge_non_codegen_optional_field(&mut base._discriminator, overlay._discriminator);
2272 base.enum_values =
2273 merge_enum_values(base.enum_values.take(), overlay.enum_values, context, self)?;
2274 merge_non_codegen_optional_field(&mut base.any_of, overlay.any_of);
2276 merge_non_codegen_optional_field(&mut base.one_of, overlay.one_of);
2277
2278 let base_required = base.required.take();
2279 let overlay_required = overlay.required;
2280 base.required = match (base_required, overlay_required) {
2281 (None, None) => None,
2282 (left, right) => Some(merge_required(
2283 left.unwrap_or_default(),
2284 right.unwrap_or_default(),
2285 )),
2286 };
2287
2288 match (base.items.take(), overlay.items) {
2289 (Some(left), Some(right)) => {
2290 base.items = Some(Box::new(self.merge_schemas(*left, *right, context)?));
2291 }
2292 (Some(left), None) => base.items = Some(left),
2293 (None, Some(right)) => base.items = Some(right),
2294 (None, None) => {}
2295 }
2296
2297 match (
2298 base.additional_properties.take(),
2299 overlay.additional_properties,
2300 ) {
2301 (
2302 Some(AdditionalProperties::Schema(left)),
2303 Some(AdditionalProperties::Schema(right)),
2304 ) => {
2305 base.additional_properties = Some(AdditionalProperties::Schema(Box::new(
2306 self.merge_schemas(*left, *right, context)?,
2307 )));
2308 }
2309 (Some(AdditionalProperties::Bool(left)), Some(AdditionalProperties::Bool(right)))
2310 if left == right =>
2311 {
2312 base.additional_properties = Some(AdditionalProperties::Bool(left));
2313 }
2314 (Some(value), None) => base.additional_properties = Some(value),
2315 (None, Some(value)) => base.additional_properties = Some(value),
2316 (Some(left), Some(_right)) => {
2317 base.additional_properties = Some(left);
2320 }
2321 (None, None) => {}
2322 }
2323
2324 let base_properties = base.properties.take();
2325 let overlay_properties = overlay.properties;
2326 base.properties = match (base_properties, overlay_properties) {
2327 (None, None) => None,
2328 (left, right) => Some(merge_properties(
2329 self,
2330 left.unwrap_or_default(),
2331 right.unwrap_or_default(),
2332 context,
2333 )?),
2334 };
2335
2336 for (key, value) in overlay.extra_keywords {
2337 match base.extra_keywords.get(&key) {
2338 Some(existing) if existing != &value => {
2339 if is_known_ignored_schema_keyword(&key) || key.starts_with("x-") {
2340 continue;
2341 }
2342 self.handle_unhandled(
2343 context,
2344 DiagnosticKind::IncompatibleAllOfField { field: key.clone() },
2345 )?;
2346 }
2347 Some(_) => {}
2348 None => {
2349 base.extra_keywords.insert(key, value);
2350 }
2351 }
2352 }
2353
2354 Ok(base)
2355 }
2356
2357 fn collect_object_schema_view(
2358 &mut self,
2359 schema: &Schema,
2360 context: &str,
2361 ) -> Result<ObjectSchemaView> {
2362 let mut view = ObjectSchemaView::default();
2363 self.collect_object_schema_view_into(schema, context, &mut view)?;
2364 Ok(view)
2365 }
2366
2367 fn collect_object_schema_view_into(
2368 &mut self,
2369 schema: &Schema,
2370 context: &str,
2371 view: &mut ObjectSchemaView,
2372 ) -> Result<()> {
2373 self.validate_schema_keywords(schema, context)?;
2374
2375 if let Some(reference) = &schema.reference {
2376 if self
2377 .active_object_view_refs
2378 .iter()
2379 .any(|item| item == reference)
2380 {
2381 self.handle_unhandled(
2382 context,
2383 DiagnosticKind::AllOfRecursiveCycle {
2384 reference: reference.clone(),
2385 },
2386 )?;
2387 return Ok(());
2388 }
2389
2390 self.active_object_view_refs.push(reference.clone());
2391 let resolved = self.resolve_schema_reference(reference)?;
2392 self.collect_object_schema_view_into(&resolved, reference, view)?;
2393 self.active_object_view_refs.pop();
2394 }
2395
2396 if let Some(members) = &schema.all_of {
2397 for member in members {
2398 self.collect_object_schema_view_into(member, context, view)?;
2399 }
2400 }
2401
2402 merge_non_codegen_optional_field(&mut view.title, schema.title.clone());
2403
2404 if let Some(required) = &schema.required {
2405 view.required.extend(required.iter().cloned());
2406 }
2407
2408 if let Some(properties) = &schema.properties {
2409 for (field_name, property_schema_or_bool) in properties {
2410 let Some(property_schema) = property_schema_or_bool.as_schema() else {
2412 continue;
2413 };
2414 if let Some(existing) = view.properties.shift_remove(field_name) {
2415 view.properties.insert(
2416 field_name.clone(),
2417 self.merge_schemas(existing, property_schema.clone(), context)?,
2418 );
2419 } else {
2420 view.properties
2421 .insert(field_name.clone(), property_schema.clone());
2422 }
2423 }
2424 }
2425
2426 Ok(())
2427 }
2428
2429 fn import_const_type(
2430 &mut self,
2431 schema: &Schema,
2432 const_value: &Value,
2433 context: &InlineModelContext,
2434 ) -> Result<ImportedType> {
2435 if let Some(schema_type) = schema.primary_schema_type() {
2436 let imported = match schema_type {
2437 "string" => {
2438 if schema.format.as_deref() == Some("binary") {
2439 ImportedType::plain(TypeRef::primitive("binary"))
2440 } else {
2441 ImportedType::plain(TypeRef::primitive("string"))
2442 }
2443 }
2444 "integer" => ImportedType::plain(TypeRef::primitive("integer")),
2445 "number" => ImportedType::plain(TypeRef::primitive("number")),
2446 "boolean" => ImportedType::plain(TypeRef::primitive("boolean")),
2447 "null" => ImportedType {
2448 type_ref: Some(TypeRef::primitive("any")),
2449 nullable: true,
2450 },
2451 "array" => {
2452 match schema.items.as_ref() {
2453 Some(item_schema) => {
2454 let imported = self.import_schema_type(item_schema, context)?;
2455 ImportedType::plain(TypeRef::array(
2456 imported
2457 .type_ref
2458 .unwrap_or_else(|| TypeRef::primitive("any")),
2459 ))
2460 }
2461 None => ImportedType::plain(TypeRef::array(TypeRef::primitive("any"))),
2463 }
2464 }
2465 "object" => self.import_object_type(schema, context, None)?,
2466 other => {
2467 self.handle_unhandled(
2468 &context.describe(),
2469 DiagnosticKind::UnsupportedSchemaType {
2470 schema_type: other.to_owned(),
2471 },
2472 )?;
2473 ImportedType::plain(TypeRef::primitive("any"))
2474 }
2475 };
2476 return Ok(imported);
2477 }
2478
2479 let imported = match const_value {
2480 Value::String(_) => ImportedType::plain(TypeRef::primitive("string")),
2481 Value::Bool(_) => ImportedType::plain(TypeRef::primitive("boolean")),
2482 Value::Number(number) => {
2483 if number.is_i64() || number.is_u64() {
2484 ImportedType::plain(TypeRef::primitive("integer"))
2485 } else {
2486 ImportedType::plain(TypeRef::primitive("number"))
2487 }
2488 }
2489 Value::Null => ImportedType {
2490 type_ref: Some(TypeRef::primitive("any")),
2491 nullable: true,
2492 },
2493 Value::Array(_) => {
2494 if let Some(items) = &schema.items {
2495 let imported = self.import_schema_type(items, context)?;
2496 ImportedType::plain(TypeRef::array(
2497 imported
2498 .type_ref
2499 .unwrap_or_else(|| TypeRef::primitive("any")),
2500 ))
2501 } else {
2502 ImportedType::plain(TypeRef::array(TypeRef::primitive("any")))
2503 }
2504 }
2505 Value::Object(_) => self.import_object_type(schema, context, None)?,
2506 };
2507
2508 Ok(imported)
2509 }
2510
2511 fn import_any_of(
2512 &mut self,
2513 schemas: &[Schema],
2514 context: &InlineModelContext,
2515 ) -> Result<ImportedType> {
2516 let mut variants = Vec::new();
2517 let mut nullable = false;
2518
2519 for schema in schemas {
2520 if schema.is_exact_null_type() {
2521 nullable = true;
2522 continue;
2523 }
2524
2525 let imported = self.import_schema_type(schema, context)?;
2526 if imported.nullable {
2527 nullable = true;
2528 }
2529 if let Some(type_ref) = imported.type_ref {
2530 variants.push(type_ref);
2531 }
2532 }
2533
2534 variants = dedupe_variants(variants);
2535 let type_ref = match variants.len() {
2536 0 => Some(TypeRef::primitive("any")),
2537 1 => variants.into_iter().next(),
2538 _ => Some(TypeRef::Union { variants }),
2539 };
2540
2541 Ok(ImportedType { type_ref, nullable })
2542 }
2543
2544 fn import_object_type(
2545 &mut self,
2546 schema: &Schema,
2547 context: &InlineModelContext,
2548 local_reference: Option<&str>,
2549 ) -> Result<ImportedType> {
2550 if let Some(additional_properties) = &schema.additional_properties {
2551 match additional_properties {
2552 AdditionalProperties::Schema(additional_properties) => {
2553 let imported = self.import_schema_type(additional_properties, context)?;
2554 return Ok(ImportedType::plain(TypeRef::map(
2555 imported
2556 .type_ref
2557 .unwrap_or_else(|| TypeRef::primitive("any")),
2558 )));
2559 }
2560 AdditionalProperties::Bool(true) => {
2561 return Ok(ImportedType::plain(TypeRef::map(TypeRef::primitive("any"))));
2562 }
2563 AdditionalProperties::Bool(false) => {}
2564 }
2565 }
2566
2567 if schema.properties.is_some() {
2568 let model_name = if let Some(reference) = local_reference {
2569 self.local_ref_model_names
2570 .get(reference)
2571 .cloned()
2572 .unwrap_or_else(|| {
2573 let model_name = self.inline_model_name(schema, context);
2574 self.local_ref_model_names
2575 .insert(reference.to_owned(), model_name.clone());
2576 model_name
2577 })
2578 } else {
2579 self.inline_model_name(schema, context)
2580 };
2581
2582 if self.models.contains_key(&model_name)
2583 || self.active_model_builds.contains(&model_name)
2584 {
2585 return Ok(ImportedType::plain(TypeRef::named(model_name)));
2586 }
2587
2588 self.active_model_builds.insert(model_name.clone());
2589 if !self.models.contains_key(&model_name) {
2590 let pointer = context.synthetic_pointer(&model_name);
2591 let build_result = self.build_model_from_schema(&model_name, schema, &pointer);
2592 self.active_model_builds.remove(&model_name);
2593 let model = build_result?;
2594 self.generated_model_names.insert(model_name.clone());
2595 self.models.insert(model_name.clone(), model);
2596 } else {
2597 self.active_model_builds.remove(&model_name);
2598 }
2599 return Ok(ImportedType::plain(TypeRef::named(model_name)));
2600 }
2601
2602 Ok(ImportedType::plain(TypeRef::primitive("object")))
2603 }
2604
2605 fn inline_model_name(&mut self, schema: &Schema, context: &InlineModelContext) -> String {
2606 let base = schema.title.clone().unwrap_or_else(|| context.name_hint());
2607 let candidate = to_pascal_case(&base);
2608 if self.generated_model_names.insert(candidate.clone()) {
2609 return candidate;
2610 }
2611
2612 let mut index = 2usize;
2613 loop {
2614 let candidate = format!("{candidate}{index}");
2615 if self.generated_model_names.insert(candidate.clone()) {
2616 return candidate;
2617 }
2618 index += 1;
2619 }
2620 }
2621
2622 fn handle_unhandled(&mut self, context: &str, kind: DiagnosticKind) -> Result<()> {
2623 let diagnostic = self.make_diagnostic(context, kind);
2624 if self.options.ignore_unhandled {
2625 self.warnings.push(diagnostic);
2626 Ok(())
2627 } else {
2628 Err(anyhow::Error::new(diagnostic))
2629 }
2630 }
2631
2632 fn make_diagnostic(&self, context: &str, kind: DiagnosticKind) -> OpenApiDiagnostic {
2636 if context.starts_with("#/") {
2637 let (preview, line) = self.source.pointer_info(context);
2638 OpenApiDiagnostic::from_pointer(kind, context, preview, line)
2639 } else {
2640 OpenApiDiagnostic::from_named_context(kind, context)
2641 }
2642 }
2643
2644 fn make_pointer_diagnostic(&self, pointer: &str, kind: DiagnosticKind) -> OpenApiDiagnostic {
2647 let (preview, line) = self.source.pointer_info(pointer);
2648 OpenApiDiagnostic::from_pointer(kind, pointer, preview, line)
2649 }
2650}
2651
2652#[derive(Debug)]
2653struct LoadedOpenApiDocument {
2654 document: OpenApiDocument,
2655 source: OpenApiSource,
2656}
2657
2658#[derive(Debug)]
2659struct OpenApiSource {
2660 format: SourceFormat,
2661 raw: String,
2662 value: OnceLock<Option<Value>>,
2663 line_map: OnceLock<Option<HashMap<String, usize>>>,
2666}
2667
2668#[derive(Debug, Clone, Copy)]
2669enum SourceFormat {
2670 Json,
2671 Yaml,
2672}
2673
2674impl OpenApiSource {
2675 fn new(format: SourceFormat, raw: String) -> Self {
2676 Self {
2677 format,
2678 raw,
2679 value: OnceLock::new(),
2680 line_map: OnceLock::new(),
2681 }
2682 }
2683
2684 fn render_pointer_preview(&self, pointer: &str) -> Option<String> {
2685 let node = self
2686 .value
2687 .get_or_init(|| self.parse_value())
2688 .as_ref()?
2689 .pointer(pointer.strip_prefix('#').unwrap_or(pointer))?;
2690 let rendered = match self.format {
2691 SourceFormat::Json => serde_json::to_string_pretty(node).ok()?,
2692 SourceFormat::Yaml => serde_yaml::to_string(node).ok()?,
2693 };
2694 Some(truncate_preview(&rendered, 10))
2695 }
2696
2697 fn pointer_info(&self, pointer: &str) -> (Option<String>, Option<usize>) {
2705 match self.format {
2706 SourceFormat::Yaml => {
2707 let key = pointer.strip_prefix('#').unwrap_or(pointer);
2708 let line = self
2709 .line_map
2710 .get_or_init(|| Some(build_yaml_line_map(&self.raw)))
2711 .as_ref()
2712 .and_then(|m| m.get(key).copied());
2713 let preview = line.map(|l| self.raw_preview_from_line(l));
2714 (preview, line)
2715 }
2716 SourceFormat::Json => {
2717 let preview = self.render_pointer_preview(pointer);
2718 let line = self.resolve_pointer_line_heuristic(pointer);
2719 (preview, line)
2720 }
2721 }
2722 }
2723
2724 fn raw_preview_from_line(&self, start_line: usize) -> String {
2727 const MAX_LINES: usize = 10;
2728 let lines: Vec<&str> = self
2729 .raw
2730 .lines()
2731 .skip(start_line.saturating_sub(1))
2732 .take(MAX_LINES)
2733 .collect();
2734 let indent = lines
2737 .iter()
2738 .filter(|l| !l.trim().is_empty())
2739 .map(|l| l.len() - l.trim_start().len())
2740 .min()
2741 .unwrap_or(0);
2742 let dedented: Vec<&str> = lines.iter().map(|l| &l[indent.min(l.len())..]).collect();
2743 dedented.join("\n")
2744 }
2745 fn resolve_pointer_line(&self, pointer: &str) -> Option<usize> {
2751 let key = pointer.strip_prefix('#').unwrap_or(pointer);
2755 match self.format {
2756 SourceFormat::Yaml => self
2757 .line_map
2758 .get_or_init(|| Some(build_yaml_line_map(&self.raw)))
2759 .as_ref()
2760 .and_then(|m| m.get(key).copied()),
2761 SourceFormat::Json => self.resolve_pointer_line_heuristic(pointer),
2762 }
2763 }
2764
2765 fn resolve_pointer_line_heuristic(&self, pointer: &str) -> Option<usize> {
2767 let inner = pointer.strip_prefix('#').unwrap_or(pointer);
2768 let segments: Vec<String> = inner
2769 .split('/')
2770 .filter(|s| !s.is_empty())
2771 .map(|s| s.replace("~1", "/").replace("~0", "~"))
2772 .collect();
2773
2774 let lines: Vec<&str> = self.raw.lines().collect();
2775 let mut search_from = 0usize;
2776 let mut last_found: Option<usize> = None;
2777
2778 for segment in &segments {
2779 let yaml_pat = format!("{}:", segment);
2780 let json_pat = format!("\"{}\":", segment);
2781 for (idx, line) in lines.iter().enumerate().skip(search_from) {
2782 let trimmed = line.trim();
2783 if trimmed.starts_with(&yaml_pat) || trimmed.starts_with(&json_pat) {
2784 last_found = Some(idx + 1);
2785 search_from = idx + 1;
2786 break;
2787 }
2788 }
2789 }
2790 last_found
2791 }
2792
2793 fn parse_value(&self) -> Option<Value> {
2794 match self.format {
2795 SourceFormat::Json => serde_json::from_str(&self.raw).ok(),
2796 SourceFormat::Yaml => {
2797 let yaml_value: serde_yaml::Value = serde_yaml::from_str(&self.raw).ok()?;
2798 serde_json::to_value(yaml_value).ok()
2799 }
2800 }
2801 }
2802}
2803
2804fn truncate_preview(rendered: &str, max_lines: usize) -> String {
2805 let lines = rendered.lines().collect::<Vec<_>>();
2806 if lines.len() <= max_lines {
2807 return rendered.to_owned();
2808 }
2809
2810 let mut output = lines
2811 .into_iter()
2812 .take(max_lines)
2813 .map(ToOwned::to_owned)
2814 .collect::<Vec<_>>();
2815 output.push("...".into());
2816 output.join("\n")
2817}
2818
2819fn build_yaml_line_map(raw: &str) -> HashMap<String, usize> {
2825 use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser};
2826 use yaml_rust2::scanner::Marker;
2827
2828 enum Frame {
2829 Mapping {
2830 ptr: String,
2831 expecting_key: bool,
2834 pending_key: String,
2835 pending_line: usize,
2840 },
2841 Sequence {
2842 ptr: String,
2843 index: usize,
2844 },
2845 }
2846
2847 struct Collector {
2848 stack: Vec<Frame>,
2849 map: HashMap<String, usize>,
2850 }
2851
2852 fn enc(key: &str) -> String {
2854 key.replace('~', "~0").replace('/', "~1")
2855 }
2856
2857 impl MarkedEventReceiver for Collector {
2858 fn on_event(&mut self, ev: Event, mark: Marker) {
2859 let line = mark.line();
2861
2862 match ev {
2863 Event::MappingStart(..) | Event::SequenceStart(..) => {
2865 let is_mapping = matches!(ev, Event::MappingStart(..));
2866
2867 let (child_ptr, record_line) = match self.stack.last() {
2871 None => (String::new(), None), Some(Frame::Mapping {
2873 ptr,
2874 expecting_key: false,
2875 pending_key,
2876 pending_line,
2877 }) => {
2878 (format!("{}/{}", ptr, enc(pending_key)), Some(*pending_line))
2883 }
2884 Some(Frame::Sequence { ptr, index }) => {
2885 (format!("{}/{}", ptr, index), Some(line))
2886 }
2887 _ => return,
2890 };
2891
2892 if let Some(l) = record_line {
2895 self.map.insert(child_ptr.clone(), l);
2896 }
2897 match self.stack.last_mut() {
2898 Some(Frame::Mapping { expecting_key, .. }) => *expecting_key = true,
2899 Some(Frame::Sequence { index, .. }) => *index += 1,
2900 None => {}
2901 }
2902 if is_mapping {
2903 self.stack.push(Frame::Mapping {
2904 ptr: child_ptr,
2905 expecting_key: true,
2906 pending_key: String::new(),
2907 pending_line: 0,
2908 });
2909 } else {
2910 self.stack.push(Frame::Sequence {
2911 ptr: child_ptr,
2912 index: 0,
2913 });
2914 }
2915 }
2916
2917 Event::MappingEnd | Event::SequenceEnd => {
2919 self.stack.pop();
2920 }
2921
2922 Event::Scalar(value, ..) => {
2924 let is_key = matches!(
2926 self.stack.last(),
2927 Some(Frame::Mapping { expecting_key: true, .. })
2928 );
2929 let value_info: Option<(String, usize)> = if !is_key {
2930 match self.stack.last() {
2931 Some(Frame::Mapping {
2932 ptr,
2933 expecting_key: false,
2934 pending_key,
2935 pending_line,
2936 }) => Some((format!("{}/{}", ptr, enc(pending_key)), *pending_line)),
2937 Some(Frame::Sequence { ptr, index }) => {
2938 Some((format!("{}/{}", ptr, index), line))
2939 }
2940 _ => None,
2941 }
2942 } else {
2943 None
2944 };
2945
2946 if is_key {
2948 if let Some(Frame::Mapping {
2949 expecting_key,
2950 pending_key,
2951 pending_line,
2952 ..
2953 }) = self.stack.last_mut()
2954 {
2955 *pending_key = value;
2956 *pending_line = line;
2957 *expecting_key = false;
2958 }
2959 } else if let Some((child_ptr, record_line)) = value_info {
2960 self.map.insert(child_ptr, record_line);
2961 match self.stack.last_mut() {
2962 Some(Frame::Mapping { expecting_key, .. }) => *expecting_key = true,
2963 Some(Frame::Sequence { index, .. }) => *index += 1,
2964 _ => {}
2965 }
2966 }
2967 }
2968
2969 _ => {}
2970 }
2971 }
2972 }
2973
2974 let mut collector = Collector {
2975 stack: Vec::new(),
2976 map: HashMap::new(),
2977 };
2978 let mut parser = Parser::new(raw.chars());
2979 if parser.load(&mut collector, false).is_err() {
2980 return HashMap::new();
2981 }
2982 collector.map
2983}
2984
2985#[derive(Debug, Clone)]
2986struct ImportedType {
2987 type_ref: Option<TypeRef>,
2988 nullable: bool,
2989}
2990
2991impl ImportedType {
2992 fn plain(type_ref: TypeRef) -> Self {
2993 Self {
2994 type_ref: Some(type_ref),
2995 nullable: false,
2996 }
2997 }
2998}
2999
3000#[derive(Default)]
3001struct ObjectSchemaView {
3002 title: Option<String>,
3003 properties: IndexMap<String, Schema>,
3004 required: BTreeSet<String>,
3005}
3006
3007#[derive(Debug)]
3008enum InlineModelContext {
3009 NamedSchema {
3010 name: String,
3011 pointer: String,
3012 },
3013 Field {
3014 model_name: String,
3015 field_name: String,
3016 pointer: String,
3017 },
3018 RequestBody {
3019 operation_name: String,
3020 pointer: String,
3021 },
3022 Response {
3023 operation_name: String,
3024 status: String,
3025 pointer: String,
3026 },
3027 Parameter {
3028 name: String,
3029 },
3030}
3031
3032impl InlineModelContext {
3033 fn name_hint(&self) -> String {
3034 match self {
3035 Self::NamedSchema { name, .. } => name.clone(),
3036 Self::Field {
3037 model_name,
3038 field_name,
3039 ..
3040 } => format!("{model_name} {field_name}"),
3041 Self::RequestBody { operation_name, .. } => format!("{operation_name} request"),
3042 Self::Response {
3043 operation_name,
3044 status,
3045 ..
3046 } => format!("{operation_name} {status} response"),
3047 Self::Parameter { name } => format!("{name} param"),
3048 }
3049 }
3050
3051 fn describe(&self) -> String {
3052 match self {
3053 InlineModelContext::NamedSchema { pointer, .. } => pointer.clone(),
3054 InlineModelContext::Field { pointer, .. } => pointer.clone(),
3055 InlineModelContext::RequestBody { pointer, .. } => pointer.clone(),
3056 InlineModelContext::Response { pointer, .. } => pointer.clone(),
3057 InlineModelContext::Parameter { name } => format!("parameter `{name}`"),
3058 }
3059 }
3060
3061 fn synthetic_pointer(&self, model_name: &str) -> String {
3062 match self {
3063 Self::NamedSchema { pointer, .. } => pointer.clone(),
3064 Self::Field { pointer, .. } => pointer.clone(),
3065 Self::RequestBody { pointer, .. } => pointer.clone(),
3066 Self::Response { pointer, .. } => pointer.clone(),
3067 Self::Parameter { name } => format!("#/synthetic/parameters/{name}/{model_name}"),
3068 }
3069 }
3070}
3071
3072#[derive(Debug, Deserialize, Clone)]
3076struct Swagger2Document {
3077 #[serde(default)]
3078 #[serde(deserialize_with = "deserialize_paths_map")]
3079 paths: BTreeMap<String, PathItem>,
3080 #[serde(default)]
3081 consumes: Vec<String>,
3082 #[serde(default)]
3083 parameters: BTreeMap<String, ParameterSpec>,
3084 #[serde(rename = "definitions")]
3085 #[serde(default)]
3086 definitions: BTreeMap<String, Schema>,
3087 #[serde(default)]
3088 responses: BTreeMap<String, ResponseSpec>,
3089}
3090
3091#[derive(Debug, Deserialize, Clone)]
3094struct OpenApi3Document {
3095 #[serde(default)]
3096 #[serde(deserialize_with = "deserialize_paths_map")]
3097 paths: BTreeMap<String, PathItem>,
3098 #[serde(default)]
3099 components: Components,
3100}
3101
3102#[derive(Debug, Deserialize, Clone)]
3105struct OpenApiDocument {
3106 #[serde(default)]
3107 #[serde(deserialize_with = "deserialize_paths_map")]
3108 paths: BTreeMap<String, PathItem>,
3109 #[serde(default)]
3110 consumes: Vec<String>,
3111 #[serde(default)]
3112 parameters: BTreeMap<String, ParameterSpec>,
3113 #[serde(rename = "definitions")]
3114 #[serde(default)]
3115 definitions: BTreeMap<String, Schema>,
3116 #[serde(default)]
3117 responses: BTreeMap<String, ResponseSpec>,
3118 #[serde(default)]
3119 components: Components,
3120}
3121
3122impl From<Swagger2Document> for OpenApiDocument {
3123 fn from(doc: Swagger2Document) -> Self {
3124 Self {
3125 paths: doc.paths,
3126 consumes: doc.consumes,
3127 parameters: doc.parameters,
3128 definitions: doc.definitions,
3129 responses: doc.responses,
3130 components: Components::default(),
3131 }
3132 }
3133}
3134
3135impl From<OpenApi3Document> for OpenApiDocument {
3136 fn from(doc: OpenApi3Document) -> Self {
3137 Self {
3138 paths: doc.paths,
3139 consumes: Vec::new(),
3140 parameters: BTreeMap::new(),
3141 definitions: BTreeMap::new(),
3142 responses: BTreeMap::new(),
3143 components: doc.components,
3144 }
3145 }
3146}
3147
3148#[derive(Debug, Deserialize, Default, Clone)]
3149struct Components {
3150 #[serde(default)]
3151 schemas: BTreeMap<String, Schema>,
3152 #[serde(default)]
3153 parameters: BTreeMap<String, ParameterSpec>,
3154 #[serde(rename = "requestBodies")]
3155 #[serde(default)]
3156 request_bodies: BTreeMap<String, RequestBodyOrRef>,
3157 #[serde(default)]
3158 responses: BTreeMap<String, ResponseSpec>,
3159}
3160
3161#[derive(Debug, Deserialize, Default, Clone)]
3162struct PathItem {
3163 #[serde(default)]
3164 parameters: Option<Vec<ParameterOrRef>>,
3165 #[serde(default)]
3166 get: Option<OperationSpec>,
3167 #[serde(default)]
3168 post: Option<OperationSpec>,
3169 #[serde(default)]
3170 put: Option<OperationSpec>,
3171 #[serde(default)]
3172 patch: Option<OperationSpec>,
3173 #[serde(default)]
3174 delete: Option<OperationSpec>,
3175}
3176
3177#[derive(Debug, Deserialize, Default, Clone)]
3178struct OperationSpec {
3179 #[serde(rename = "operationId")]
3180 #[serde(default)]
3181 operation_id: Option<String>,
3182 #[serde(default)]
3183 summary: Option<String>,
3184 #[serde(default)]
3185 tags: Vec<String>,
3186 #[serde(default)]
3187 parameters: Vec<ParameterOrRef>,
3188 #[serde(default)]
3189 consumes: Vec<String>,
3190 #[serde(rename = "requestBody")]
3191 #[serde(default)]
3192 request_body: Option<RequestBodyOrRef>,
3193 #[serde(default)]
3194 responses: BTreeMap<String, ResponseSpecOrRef>,
3195}
3196
3197#[derive(Debug, Deserialize, Clone)]
3198struct ParameterSpec {
3199 name: String,
3200 #[serde(default)]
3201 description: String,
3202 #[serde(rename = "in")]
3203 location: RawParameterLocation,
3204 #[serde(default)]
3205 required: bool,
3206 #[serde(default)]
3207 schema: Option<Schema>,
3208 #[serde(rename = "type")]
3209 #[serde(default)]
3210 parameter_type: Option<SchemaTypeDecl>,
3211 #[serde(default)]
3212 format: Option<String>,
3213 #[serde(default)]
3214 items: Option<Box<Schema>>,
3215 #[serde(rename = "collectionFormat")]
3216 #[serde(default)]
3217 collection_format: Option<String>,
3218 #[serde(default)]
3220 content: BTreeMap<String, MediaTypeSpec>,
3221}
3222
3223impl ParameterSpec {
3224 fn effective_schema(&self) -> Option<Schema> {
3225 self.schema
3226 .clone()
3227 .or_else(|| {
3228 self.parameter_type.clone().map(|schema_type| Schema {
3229 schema_type: Some(schema_type),
3230 format: self.format.clone(),
3231 items: self.items.clone(),
3232 ..Schema::default()
3233 })
3234 })
3235 .or_else(|| {
3236 self.content
3239 .values()
3240 .next()
3241 .and_then(|media| media.schema.clone())
3242 })
3243 }
3244}
3245
3246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3247enum RawParameterLocation {
3248 Path,
3249 Query,
3250 Header,
3251 Cookie,
3252 Body,
3253 FormData,
3254}
3255
3256impl RawParameterLocation {
3257 fn as_ir_location(self) -> Option<ParameterLocation> {
3258 match self {
3259 Self::Path => Some(ParameterLocation::Path),
3260 Self::Query => Some(ParameterLocation::Query),
3261 Self::Header => Some(ParameterLocation::Header),
3262 Self::Cookie => Some(ParameterLocation::Cookie),
3263 Self::Body | Self::FormData => None,
3264 }
3265 }
3266}
3267
3268impl<'de> Deserialize<'de> for RawParameterLocation {
3269 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
3270 where
3271 D: serde::Deserializer<'de>,
3272 {
3273 let value = String::deserialize(deserializer)?;
3274 match value.as_str() {
3275 "path" => Ok(Self::Path),
3276 "query" => Ok(Self::Query),
3277 "header" => Ok(Self::Header),
3278 "cookie" => Ok(Self::Cookie),
3279 "body" => Ok(Self::Body),
3280 "formData" | "formdata" => Ok(Self::FormData),
3281 _ => Err(serde::de::Error::unknown_variant(
3282 &value,
3283 &["path", "query", "header", "cookie", "body", "formData"],
3284 )),
3285 }
3286 }
3287}
3288
3289fn raw_parameter_location_label(location: RawParameterLocation) -> &'static str {
3290 match location {
3291 RawParameterLocation::Path => "path",
3292 RawParameterLocation::Query => "query",
3293 RawParameterLocation::Header => "header",
3294 RawParameterLocation::Cookie => "cookie",
3295 RawParameterLocation::Body => "body",
3296 RawParameterLocation::FormData => "form_data",
3297 }
3298}
3299
3300#[derive(Debug, Deserialize, Clone)]
3301#[serde(untagged)]
3302enum ParameterOrRef {
3303 Ref {
3304 #[serde(rename = "$ref")]
3305 reference: String,
3306 },
3307 Inline(ParameterSpec),
3308}
3309
3310#[derive(Debug, Deserialize, Default, Clone)]
3311struct RequestBodySpec {
3312 #[serde(default)]
3313 required: bool,
3314 #[serde(default)]
3315 content: BTreeMap<String, MediaTypeSpec>,
3316}
3317
3318#[derive(Debug, Deserialize, Clone)]
3319#[serde(untagged)]
3320enum RequestBodyOrRef {
3321 Ref {
3322 #[serde(rename = "$ref")]
3323 reference: String,
3324 },
3325 Inline(RequestBodySpec),
3326}
3327
3328#[derive(Debug, Deserialize, Default, Clone)]
3333struct ResponseSpecOrRef {
3334 #[serde(rename = "$ref")]
3335 #[serde(default)]
3336 reference: Option<String>,
3337 #[serde(default)]
3338 description: String,
3339 #[serde(default)]
3340 content: BTreeMap<String, MediaTypeSpec>,
3341}
3342
3343#[derive(Debug, Deserialize, Default, Clone)]
3344struct ResponseSpec {
3345 #[serde(default)]
3346 description: String,
3347 #[serde(default)]
3348 content: BTreeMap<String, MediaTypeSpec>,
3349}
3350
3351#[derive(Debug, Deserialize, Default, Clone)]
3352struct MediaTypeSpec {
3353 #[serde(default)]
3354 schema: Option<Schema>,
3355}
3356
3357#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
3358struct Schema {
3359 #[serde(rename = "$ref")]
3360 #[serde(default)]
3361 reference: Option<String>,
3362 #[serde(default)]
3363 definitions: Option<BTreeMap<String, Schema>>,
3364 #[serde(rename = "type")]
3365 #[serde(default)]
3366 schema_type: Option<SchemaTypeDecl>,
3367 #[serde(default)]
3368 title: Option<String>,
3369 #[serde(default)]
3370 format: Option<String>,
3371 #[serde(rename = "const")]
3372 #[serde(default)]
3373 const_value: Option<Value>,
3374 #[serde(rename = "discriminator")]
3375 #[serde(default)]
3376 _discriminator: Option<Value>,
3377 #[serde(rename = "allOf")]
3378 #[serde(default)]
3379 all_of: Option<Vec<Schema>>,
3380 #[serde(rename = "enum")]
3381 #[serde(default)]
3382 enum_values: Option<Vec<Value>>,
3383 #[serde(default)]
3384 properties: Option<IndexMap<String, SchemaOrBool>>,
3385 #[serde(default)]
3386 required: Option<Vec<String>>,
3387 #[serde(default)]
3388 items: Option<Box<Schema>>,
3389 #[serde(rename = "additionalProperties")]
3390 #[serde(default)]
3391 additional_properties: Option<AdditionalProperties>,
3392 #[serde(rename = "anyOf")]
3393 #[serde(default)]
3394 any_of: Option<Vec<Schema>>,
3395 #[serde(rename = "oneOf")]
3396 #[serde(default)]
3397 one_of: Option<Vec<Schema>>,
3398 #[serde(default)]
3401 minimum: Option<Value>,
3402 #[serde(default)]
3403 maximum: Option<Value>,
3404 #[serde(rename = "exclusiveMinimum")]
3405 #[serde(default)]
3406 exclusive_minimum: Option<Value>,
3407 #[serde(rename = "exclusiveMaximum")]
3408 #[serde(default)]
3409 exclusive_maximum: Option<Value>,
3410 #[serde(default)]
3411 #[serde(rename = "multipleOf")]
3412 multiple_of: Option<Value>,
3413 #[serde(default)]
3414 #[serde(rename = "minLength")]
3415 min_length: Option<Value>,
3416 #[serde(default)]
3417 #[serde(rename = "maxLength")]
3418 max_length: Option<Value>,
3419 #[serde(default)]
3420 #[serde(rename = "minItems")]
3421 min_items: Option<Value>,
3422 #[serde(default)]
3423 #[serde(rename = "maxItems")]
3424 max_items: Option<Value>,
3425 #[serde(default)]
3426 #[serde(rename = "minProperties")]
3427 min_properties: Option<Value>,
3428 #[serde(default)]
3429 #[serde(rename = "maxProperties")]
3430 max_properties: Option<Value>,
3431 #[serde(flatten)]
3432 #[serde(default)]
3433 extra_keywords: BTreeMap<String, Value>,
3434}
3435
3436#[derive(Debug, Deserialize, Clone, PartialEq)]
3437#[serde(untagged)]
3438enum AdditionalProperties {
3439 Bool(bool),
3440 Schema(Box<Schema>),
3441}
3442
3443#[derive(Debug, Clone, PartialEq)]
3447enum SchemaOrBool {
3448 Schema(Schema),
3449 Bool(bool),
3450}
3451
3452impl Default for SchemaOrBool {
3453 fn default() -> Self {
3454 SchemaOrBool::Schema(Schema::default())
3455 }
3456}
3457
3458impl SchemaOrBool {
3459 fn as_schema(&self) -> Option<&Schema> {
3461 match self {
3462 SchemaOrBool::Schema(s) => Some(s),
3463 SchemaOrBool::Bool(_) => None,
3464 }
3465 }
3466 fn into_schema(self) -> Option<Schema> {
3467 match self {
3468 SchemaOrBool::Schema(s) => Some(s),
3469 SchemaOrBool::Bool(_) => None,
3470 }
3471 }
3472}
3473
3474impl<'de> serde::Deserialize<'de> for SchemaOrBool {
3475 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
3476 where
3477 D: serde::Deserializer<'de>,
3478 {
3479 struct SchemaOrBoolVisitor;
3480 impl<'de> serde::de::Visitor<'de> for SchemaOrBoolVisitor {
3481 type Value = SchemaOrBool;
3482 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3483 write!(f, "a JSON Schema object or boolean")
3484 }
3485 fn visit_bool<E: serde::de::Error>(self, v: bool) -> std::result::Result<SchemaOrBool, E> {
3487 Ok(SchemaOrBool::Bool(v))
3488 }
3489 fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> std::result::Result<SchemaOrBool, A::Error> {
3493 let schema = Schema::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
3494 Ok(SchemaOrBool::Schema(schema))
3495 }
3496 }
3497 deserializer.deserialize_any(SchemaOrBoolVisitor)
3498 }
3499}
3500
3501#[derive(Debug, Deserialize, Clone, PartialEq)]
3502#[serde(untagged)]
3503enum SchemaTypeDecl {
3504 Single(String),
3505 Multiple(Vec<String>),
3506 Embedded(Box<Schema>),
3507}
3508
3509impl SchemaTypeDecl {
3510 fn as_slice(&self) -> &[String] {
3511 match self {
3512 Self::Single(value) => std::slice::from_ref(value),
3513 Self::Multiple(values) => values.as_slice(),
3514 Self::Embedded(_) => &[],
3515 }
3516 }
3517
3518 fn embedded_schema(&self) -> Option<&Schema> {
3519 match self {
3520 Self::Embedded(schema) => Some(schema.as_ref()),
3521 _ => None,
3522 }
3523 }
3524}
3525
3526impl Schema {
3527 fn schema_type_variants(&self) -> Option<&[String]> {
3528 self.schema_type.as_ref().map(SchemaTypeDecl::as_slice)
3529 }
3530
3531 fn primary_schema_type(&self) -> Option<&str> {
3532 self.schema_type_variants()?
3533 .iter()
3534 .find(|value| value.as_str() != "null")
3535 .map(String::as_str)
3536 }
3537
3538 fn is_exact_null_type(&self) -> bool {
3539 matches!(self.schema_type_variants(), Some([value]) if value == "null")
3540 }
3541}
3542
3543fn ref_name(reference: &str) -> Result<String> {
3544 reference
3545 .rsplit('/')
3546 .next()
3547 .filter(|value| !value.is_empty())
3548 .map(ToOwned::to_owned)
3549 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))
3550}
3551
3552fn is_named_schema_reference(reference: &str) -> bool {
3553 let Some(pointer) = reference.strip_prefix("#/") else {
3554 return false;
3555 };
3556 let segments = pointer.split('/').collect::<Vec<_>>();
3557 matches!(
3558 segments.as_slice(),
3559 ["components", "schemas", _] | ["definitions", _]
3560 )
3561}
3562
3563fn is_inline_local_schema_reference(reference: &str) -> bool {
3564 reference.starts_with("#/") && !is_named_schema_reference(reference)
3565}
3566
3567fn decode_json_pointer_segment(segment: &str) -> Result<String> {
3568 let unescaped = segment.replace("~1", "/").replace("~0", "~");
3569 percent_decode(&unescaped)
3570}
3571
3572fn percent_decode(value: &str) -> Result<String> {
3573 let bytes = value.as_bytes();
3574 let mut decoded = Vec::with_capacity(bytes.len());
3575 let mut index = 0usize;
3576 while index < bytes.len() {
3577 if bytes[index] == b'%' {
3578 if index + 2 >= bytes.len() {
3579 bail!("unsupported reference segment `{value}`");
3580 }
3581 let high = (bytes[index + 1] as char)
3582 .to_digit(16)
3583 .ok_or_else(|| anyhow!("unsupported reference segment `{value}`"))?;
3584 let low = (bytes[index + 2] as char)
3585 .to_digit(16)
3586 .ok_or_else(|| anyhow!("unsupported reference segment `{value}`"))?;
3587 decoded.push(((high << 4) | low) as u8);
3588 index += 3;
3589 } else {
3590 decoded.push(bytes[index]);
3591 index += 1;
3592 }
3593 }
3594
3595 String::from_utf8(decoded).map_err(|_| anyhow!("unsupported reference segment `{value}`"))
3596}
3597
3598fn resolve_response_schema_reference(
3601 response: &ResponseSpec,
3602 segments: &[String],
3603 reference: &str,
3604) -> Result<Schema> {
3605 match segments {
3606 [] => {
3611 let schema = response
3612 .content
3613 .values()
3614 .find_map(|media| media.schema.as_ref())
3615 .cloned()
3616 .unwrap_or_default();
3617 Ok(schema)
3618 }
3619 [content_key, media_type, schema_key, rest @ ..]
3621 if content_key == "content" && schema_key == "schema" =>
3622 {
3623 let schema = response
3624 .content
3625 .get(media_type)
3626 .and_then(|media| media.schema.as_ref())
3627 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3628 resolve_nested_schema_reference(schema, rest, reference)
3629 }
3630 _ => Err(anyhow!("unsupported reference `{reference}`")),
3631 }
3632}
3633
3634fn resolve_nested_schema_reference(
3635 schema: &Schema,
3636 segments: &[String],
3637 reference: &str,
3638) -> Result<Schema> {
3639 if segments.is_empty() {
3640 return Ok(schema.clone());
3641 }
3642
3643 match segments {
3644 [segment, name, remainder @ ..] if segment == "definitions" => {
3645 let nested = schema
3646 .definitions
3647 .as_ref()
3648 .and_then(|definitions| definitions.get(name))
3649 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3650 resolve_nested_schema_reference(nested, remainder, reference)
3651 }
3652 [segment, remainder @ ..] if segment == "allOf" => {
3653 let index = remainder
3654 .first()
3655 .and_then(|value| value.parse::<usize>().ok())
3656 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3657 let member = schema
3658 .all_of
3659 .as_ref()
3660 .and_then(|members| members.get(index))
3661 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3662 resolve_nested_schema_reference(member, &remainder[1..], reference)
3663 }
3664 [segment, remainder @ ..] if segment == "anyOf" => {
3665 let index = remainder
3666 .first()
3667 .and_then(|value| value.parse::<usize>().ok())
3668 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3669 let member = schema
3670 .any_of
3671 .as_ref()
3672 .and_then(|members| members.get(index))
3673 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3674 resolve_nested_schema_reference(member, &remainder[1..], reference)
3675 }
3676 [segment, remainder @ ..] if segment == "oneOf" => {
3677 let index = remainder
3678 .first()
3679 .and_then(|value| value.parse::<usize>().ok())
3680 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3681 let member = schema
3682 .one_of
3683 .as_ref()
3684 .and_then(|members| members.get(index))
3685 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3686 resolve_nested_schema_reference(member, &remainder[1..], reference)
3687 }
3688 [segment, name, remainder @ ..] if segment == "properties" => {
3689 if let Some(property) = schema
3691 .properties
3692 .as_ref()
3693 .and_then(|p| p.get(name))
3694 .and_then(SchemaOrBool::as_schema)
3695 {
3696 return resolve_nested_schema_reference(property, remainder, reference);
3697 }
3698 if let Some(all_of) = &schema.all_of {
3701 for member in all_of {
3702 if let Some(property) = member
3703 .properties
3704 .as_ref()
3705 .and_then(|p| p.get(name))
3706 .and_then(SchemaOrBool::as_schema)
3707 {
3708 return resolve_nested_schema_reference(property, remainder, reference);
3709 }
3710 }
3711 }
3712 Err(anyhow!("unsupported reference `{reference}`"))
3713 }
3714 [segment, remainder @ ..] if segment == "items" => {
3715 let item = schema
3716 .items
3717 .as_deref()
3718 .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3719 resolve_nested_schema_reference(item, remainder, reference)
3720 }
3721 [segment, remainder @ ..] if segment == "additionalProperties" => {
3722 let nested = match schema.additional_properties.as_ref() {
3723 Some(AdditionalProperties::Schema(schema)) => schema.as_ref(),
3724 _ => return Err(anyhow!("unsupported reference `{reference}`")),
3725 };
3726 resolve_nested_schema_reference(nested, remainder, reference)
3727 }
3728 _ => Err(anyhow!("unsupported reference `{reference}`")),
3729 }
3730}
3731
3732fn schema_is_object_like(schema: &Schema) -> bool {
3733 schema
3734 .schema_type_variants()
3735 .is_some_and(|variants| variants.iter().any(|value| value == "object"))
3736 || schema.properties.is_some()
3737 || schema.additional_properties.is_some()
3738}
3739
3740fn is_validation_only_schema_variant(schema: &Schema) -> bool {
3741 schema.reference.is_none()
3742 && schema.definitions.is_none()
3743 && schema
3744 .schema_type
3745 .as_ref()
3746 .is_none_or(|decl| matches!(decl.as_slice(), [value] if value == "object"))
3747 && schema.format.is_none()
3748 && schema.const_value.is_none()
3749 && schema._discriminator.is_none()
3750 && schema.all_of.is_none()
3751 && schema.enum_values.is_none()
3752 && schema.properties.is_none()
3753 && schema.items.is_none()
3754 && schema.additional_properties.is_none()
3755 && schema.any_of.is_none()
3756 && schema.one_of.is_none()
3757 && schema
3758 .extra_keywords
3759 .keys()
3760 .all(|keyword| is_known_ignored_schema_keyword(keyword) || keyword.starts_with("x-"))
3761}
3762
3763fn is_generic_object_placeholder(schema: &Schema) -> bool {
3764 let has_object_type = schema
3765 .schema_type
3766 .as_ref()
3767 .is_some_and(|decl| matches!(decl.as_slice(), [value] if value == "object"));
3768
3769 (has_object_type || schema.properties.is_some())
3770 && schema
3771 .properties
3772 .as_ref()
3773 .is_some_and(|properties| properties.is_empty())
3774 && schema.additional_properties.is_none()
3775 && schema.definitions.is_none()
3776 && schema.items.is_none()
3777 && schema.enum_values.is_none()
3778 && schema.const_value.is_none()
3779 && schema.any_of.is_none()
3780 && schema.one_of.is_none()
3781 && schema.all_of.is_none()
3782 && schema._discriminator.is_none()
3783}
3784
3785fn schema_runtime_attributes(schema: &Schema) -> Attributes {
3786 let mut attributes = Attributes::default();
3787 if let Some(description) = schema
3788 .extra_keywords
3789 .get("description")
3790 .and_then(Value::as_str)
3791 {
3792 attributes.insert("description".into(), Value::String(description.to_owned()));
3793 }
3794 if let Some(content_encoding) = schema
3795 .extra_keywords
3796 .get("contentEncoding")
3797 .and_then(Value::as_str)
3798 {
3799 attributes.insert(
3800 "content_encoding".into(),
3801 Value::String(content_encoding.to_owned()),
3802 );
3803 }
3804 if let Some(content_media_type) = schema
3805 .extra_keywords
3806 .get("contentMediaType")
3807 .and_then(Value::as_str)
3808 {
3809 attributes.insert(
3810 "content_media_type".into(),
3811 Value::String(content_media_type.to_owned()),
3812 );
3813 }
3814 attributes
3815}
3816
3817fn parameter_attributes(param: &ParameterSpec, schema: &Schema) -> Attributes {
3818 let mut attributes = schema_runtime_attributes(schema);
3819 if !param.description.trim().is_empty() {
3820 attributes.insert(
3821 "description".into(),
3822 Value::String(param.description.trim().to_owned()),
3823 );
3824 }
3825 if let Some(collection_format) = ¶m.collection_format {
3826 attributes.insert(
3827 "collection_format".into(),
3828 Value::String(collection_format.clone()),
3829 );
3830 }
3831 attributes
3832}
3833
3834fn is_unconstrained_schema(schema: &Schema) -> bool {
3835 schema.reference.is_none()
3836 && schema.definitions.is_none()
3837 && schema.schema_type.is_none()
3838 && schema.format.is_none()
3839 && schema.const_value.is_none()
3840 && schema._discriminator.is_none()
3841 && schema.all_of.is_none()
3842 && schema.enum_values.is_none()
3843 && schema.properties.is_none()
3844 && schema.required.is_none()
3845 && schema.items.is_none()
3846 && schema.additional_properties.is_none()
3847 && schema.any_of.is_none()
3848 && schema.one_of.is_none()
3849 && schema
3850 .extra_keywords
3851 .keys()
3852 .all(|keyword| is_known_ignored_schema_keyword(keyword) || keyword.starts_with("x-"))
3853}
3854
3855fn schema_has_non_all_of_shape(schema: &Schema) -> bool {
3856 schema.reference.is_some()
3857 || schema.definitions.is_some()
3858 || schema.schema_type.is_some()
3859 || schema.format.is_some()
3860 || schema.const_value.is_some()
3861 || schema.enum_values.is_some()
3862 || schema.properties.is_some()
3863 || schema.required.is_some()
3864 || schema.items.is_some()
3865 || schema.additional_properties.is_some()
3866 || schema.any_of.is_some()
3867 || schema.one_of.is_some()
3868 || schema._discriminator.is_some()
3869}
3870
3871fn is_known_ignored_schema_keyword(keyword: &str) -> bool {
3872 matches!(
3873 keyword,
3874 "default"
3875 | "not"
3876 | "description"
3877 | "example"
3878 | "examples"
3879 | "collectionFormat"
3880 | "contentEncoding"
3881 | "contentMediaType"
3882 | "externalDocs"
3883 | "xml"
3884 | "deprecated"
3885 | "readOnly"
3886 | "writeOnly"
3887 | "minimum"
3888 | "maximum"
3889 | "exclusiveMinimum"
3890 | "exclusiveMaximum"
3891 | "multipleOf"
3892 | "minLength"
3893 | "maxLength"
3894 | "pattern"
3895 | "minItems"
3896 | "maxItems"
3897 | "uniqueItems"
3898 | "minProperties"
3899 | "maxProperties"
3900 | "nullable"
3901 | "$schema"
3902 | "$id"
3903 | "$comment"
3904 )
3905}
3906
3907fn is_known_but_unimplemented_schema_keyword(keyword: &str) -> bool {
3908 matches!(
3909 keyword,
3910 "if" | "then"
3911 | "else"
3912 | "contains"
3913 | "prefixItems"
3914 | "patternProperties"
3915 | "propertyNames"
3916 | "dependentSchemas"
3917 | "unevaluatedProperties"
3918 | "unevaluatedItems"
3919 | "$defs"
3920 )
3921}
3922
3923fn fallback_operation_name(method: HttpMethod, path: &str) -> String {
3924 to_snake_case(&format!("{} {}", method_key(method), path))
3925}
3926
3927fn method_key(method: HttpMethod) -> &'static str {
3928 match method {
3929 HttpMethod::Get => "get",
3930 HttpMethod::Post => "post",
3931 HttpMethod::Put => "put",
3932 HttpMethod::Patch => "patch",
3933 HttpMethod::Delete => "delete",
3934 }
3935}
3936
3937fn operation_attributes(spec: &OperationSpec) -> Attributes {
3938 let mut attributes = Attributes::default();
3939 if let Some(summary) = &spec.summary {
3940 attributes.insert("summary".into(), Value::String(summary.clone()));
3941 }
3942 if !spec.tags.is_empty() {
3943 attributes.insert("tags".into(), json!(spec.tags));
3944 }
3945 attributes
3946}
3947
3948fn json_pointer_key(input: &str) -> String {
3949 input.replace('~', "~0").replace('/', "~1")
3950}
3951
3952fn to_pascal_case(input: &str) -> String {
3953 let mut output = String::new();
3954 for part in split_words(input) {
3955 let mut chars = part.chars();
3956 if let Some(first) = chars.next() {
3957 output.extend(first.to_uppercase());
3958 output.push_str(chars.as_str());
3959 }
3960 }
3961 if output.is_empty() {
3962 "InlineModel".into()
3963 } else {
3964 output
3965 }
3966}
3967
3968fn to_snake_case(input: &str) -> String {
3969 let parts = split_words(input);
3970 if parts.is_empty() {
3971 return "value".into();
3972 }
3973 parts.join("_").to_lowercase()
3974}
3975
3976fn split_words(input: &str) -> Vec<String> {
3977 let mut words = Vec::new();
3978 let mut current = String::new();
3979
3980 for ch in input.chars() {
3981 if ch.is_ascii_alphanumeric() {
3982 if ch.is_uppercase() && !current.is_empty() {
3983 words.push(current.clone());
3984 current.clear();
3985 }
3986 current.push(ch.to_ascii_lowercase());
3987 } else if !current.is_empty() {
3988 words.push(current.clone());
3989 current.clear();
3990 }
3991 }
3992
3993 if !current.is_empty() {
3994 words.push(current);
3995 }
3996
3997 words
3998}
3999
4000fn dedupe_variants(variants: Vec<TypeRef>) -> Vec<TypeRef> {
4001 let mut seen = BTreeSet::new();
4002 let mut deduped = Vec::new();
4003 for variant in variants {
4004 let key = serde_json::to_string(&variant).expect("type refs should always serialize");
4005 if seen.insert(key) {
4006 deduped.push(variant);
4007 }
4008 }
4009 deduped
4010}
4011
4012fn merge_required(mut left: Vec<String>, right: Vec<String>) -> Vec<String> {
4013 let mut seen = left.iter().cloned().collect::<BTreeSet<_>>();
4014 for value in right {
4015 if seen.insert(value.clone()) {
4016 left.push(value);
4017 }
4018 }
4019 left
4020}
4021
4022fn merge_optional_field<T>(
4023 target: &mut Option<T>,
4024 incoming: Option<T>,
4025 field_name: &str,
4026 context: &str,
4027 importer: &mut OpenApiImporter,
4028) -> Result<()>
4029where
4030 T: PartialEq,
4031{
4032 match (target.as_ref(), incoming) {
4033 (_, None) => {}
4034 (None, Some(value)) => *target = Some(value),
4035 (Some(existing), Some(value)) if *existing == value => {}
4036 (Some(_), Some(_)) => {
4037 importer.handle_unhandled(
4038 context,
4039 DiagnosticKind::IncompatibleAllOfField {
4040 field: field_name.to_owned(),
4041 },
4042 )?;
4043 }
4044 }
4045 Ok(())
4046}
4047
4048fn merge_non_codegen_optional_field<T>(target: &mut Option<T>, incoming: Option<T>) {
4049 if target.is_none() {
4050 *target = incoming;
4051 }
4052}
4053
4054fn merge_schema_types(
4055 inferred_left: Option<SchemaTypeDecl>,
4056 inferred_right: Option<SchemaTypeDecl>,
4057 left_is_generic_object_placeholder: bool,
4058 right_is_generic_object_placeholder: bool,
4059 left: Option<SchemaTypeDecl>,
4060 right: Option<SchemaTypeDecl>,
4061 _context: &str,
4062 _importer: &mut OpenApiImporter,
4063) -> Result<Option<SchemaTypeDecl>> {
4064 match (left, right) {
4065 (None, None) => Ok(inferred_left.or(inferred_right)),
4066 (Some(value), None) => Ok(Some(value)),
4067 (None, Some(value)) => Ok(Some(value)),
4068 (Some(left), Some(right)) if left == right => Ok(Some(left)),
4069 (Some(left), Some(right)) => {
4070 let left_inferred = inferred_left.unwrap_or(left.clone());
4071 let right_inferred = inferred_right.unwrap_or(right.clone());
4072 if left_is_generic_object_placeholder {
4073 return Ok(Some(right_inferred));
4074 }
4075 if right_is_generic_object_placeholder {
4076 return Ok(Some(left_inferred));
4077 }
4078 if let Some(merged) =
4079 merge_numeric_compatible_schema_types(&left_inferred, &right_inferred)
4080 {
4081 return Ok(Some(merged));
4082 }
4083 if let Some(merged) =
4084 merge_nullable_compatible_schema_types(&left_inferred, &right_inferred)
4085 {
4086 return Ok(Some(merged));
4087 }
4088 if left_inferred == right_inferred {
4089 Ok(Some(left_inferred))
4090 } else {
4091 Ok(Some(left_inferred))
4093 }
4094 }
4095 }
4096}
4097
4098fn merge_numeric_compatible_schema_types(
4099 left: &SchemaTypeDecl,
4100 right: &SchemaTypeDecl,
4101) -> Option<SchemaTypeDecl> {
4102 let left_variants = left.as_slice();
4103 let right_variants = right.as_slice();
4104 let left_has_numeric = left_variants
4105 .iter()
4106 .any(|value| value == "integer" || value == "number");
4107 let right_has_numeric = right_variants
4108 .iter()
4109 .any(|value| value == "integer" || value == "number");
4110 if !left_has_numeric || !right_has_numeric {
4111 return None;
4112 }
4113
4114 let left_other = left_variants
4115 .iter()
4116 .filter(|value| value.as_str() != "integer" && value.as_str() != "number")
4117 .collect::<BTreeSet<_>>();
4118 let right_other = right_variants
4119 .iter()
4120 .filter(|value| value.as_str() != "integer" && value.as_str() != "number")
4121 .collect::<BTreeSet<_>>();
4122 if left_other != right_other {
4123 return None;
4124 }
4125
4126 let mut merged = left_other
4127 .into_iter()
4128 .map(|value| value.to_owned())
4129 .collect::<Vec<_>>();
4130 merged.push("number".into());
4131
4132 Some(if merged.len() == 1 {
4133 SchemaTypeDecl::Single(merged.remove(0))
4134 } else {
4135 SchemaTypeDecl::Multiple(merged)
4136 })
4137}
4138
4139fn merge_nullable_compatible_schema_types(
4140 left: &SchemaTypeDecl,
4141 right: &SchemaTypeDecl,
4142) -> Option<SchemaTypeDecl> {
4143 let left_variants = left.as_slice();
4144 let right_variants = right.as_slice();
4145 if left_variants.is_empty() || right_variants.is_empty() {
4146 return None;
4147 }
4148
4149 let left_has_null = left_variants.iter().any(|value| value == "null");
4150 let right_has_null = right_variants.iter().any(|value| value == "null");
4151 if !left_has_null && !right_has_null {
4152 return None;
4153 }
4154
4155 let left_without_null = left_variants
4156 .iter()
4157 .filter(|value| value.as_str() != "null")
4158 .cloned()
4159 .collect::<BTreeSet<_>>();
4160 let right_without_null = right_variants
4161 .iter()
4162 .filter(|value| value.as_str() != "null")
4163 .cloned()
4164 .collect::<BTreeSet<_>>();
4165
4166 let merged_without_null = if left_without_null.is_empty() && !right_without_null.is_empty() {
4167 right_without_null
4168 } else if right_without_null.is_empty() && !left_without_null.is_empty() {
4169 left_without_null
4170 } else if left_without_null == right_without_null {
4171 left_without_null
4172 } else {
4173 return None;
4174 };
4175
4176 let mut merged = merged_without_null.into_iter().collect::<Vec<_>>();
4177 merged.push("null".into());
4178
4179 Some(if merged.len() == 1 {
4180 SchemaTypeDecl::Single(merged.remove(0))
4181 } else {
4182 SchemaTypeDecl::Multiple(merged)
4183 })
4184}
4185
4186fn merge_enum_values(
4187 left: Option<Vec<Value>>,
4188 right: Option<Vec<Value>>,
4189 _context: &str,
4190 _importer: &mut OpenApiImporter,
4191) -> Result<Option<Vec<Value>>> {
4192 match (left, right) {
4193 (None, None) => Ok(None),
4194 (Some(values), None) | (None, Some(values)) => Ok(Some(values)),
4195 (Some(left_values), Some(right_values)) => {
4196 let right_keys = right_values
4197 .iter()
4198 .map(serde_json::to_string)
4199 .collect::<std::result::Result<BTreeSet<_>, _>>()
4200 .expect("enum values should always serialize");
4201 let merged = left_values
4202 .iter()
4203 .filter(|value| {
4204 let key =
4205 serde_json::to_string(value).expect("enum values should always serialize");
4206 right_keys.contains(&key)
4207 })
4208 .cloned()
4209 .collect::<Vec<_>>();
4210
4211 let result = if merged.is_empty() {
4214 left_values
4215 } else {
4216 merged
4217 };
4218
4219 Ok(Some(result))
4220 }
4221 }
4222}
4223
4224fn infer_schema_type_for_merge(schema: &Schema) -> Option<SchemaTypeDecl> {
4225 schema.schema_type.clone().or_else(|| {
4226 if schema.properties.is_some() || schema.additional_properties.is_some() {
4227 Some(SchemaTypeDecl::Single("object".into()))
4228 } else if schema.items.is_some() {
4229 Some(SchemaTypeDecl::Single("array".into()))
4230 } else if let Some(enum_values) = &schema.enum_values {
4231 match infer_enum_type(enum_values, schema.format.as_deref()) {
4232 TypeRef::Primitive { name } => Some(SchemaTypeDecl::Single(name)),
4233 _ => None,
4234 }
4235 } else {
4236 infer_format_only_type(schema.format.as_deref()).and_then(|type_ref| match type_ref {
4237 TypeRef::Primitive { name } => Some(SchemaTypeDecl::Single(name)),
4238 _ => None,
4239 })
4240 }
4241 })
4242}
4243
4244fn infer_enum_type(enum_values: &[Value], format: Option<&str>) -> TypeRef {
4245 let inferred_name = if enum_values.iter().all(Value::is_string) {
4246 if format == Some("binary") {
4247 "binary"
4248 } else {
4249 "string"
4250 }
4251 } else if enum_values.iter().all(|value| value.as_i64().is_some()) {
4252 "integer"
4253 } else if enum_values.iter().all(Value::is_number) {
4254 "number"
4255 } else if enum_values.iter().all(Value::is_boolean) {
4256 "boolean"
4257 } else {
4258 "any"
4259 };
4260
4261 TypeRef::primitive(inferred_name)
4262}
4263
4264fn infer_format_only_type(format: Option<&str>) -> Option<TypeRef> {
4265 let inferred = match format? {
4266 "binary" => "binary",
4267 "boolean" | "bool" => "boolean",
4269 "integer" | "int" | "int32" | "int64" => "integer",
4270 "number" | "float" | "double" | "decimal" => "number",
4271 "string" | "byte" | "date" | "date-time" | "duration" | "email" | "hostname"
4273 | "host-name" | "ipv4" | "ipv6" | "password" | "uri" | "uuid" => "string",
4274 _ => return None,
4275 };
4276 Some(TypeRef::primitive(inferred))
4277}
4278
4279fn merge_properties(
4280 importer: &mut OpenApiImporter,
4281 mut left: IndexMap<String, SchemaOrBool>,
4282 right: IndexMap<String, SchemaOrBool>,
4283 context: &str,
4284) -> Result<IndexMap<String, SchemaOrBool>> {
4285 for (key, value) in right {
4286 if let Some(existing) = left.shift_remove(&key) {
4287 let merged = match (existing, value) {
4288 (SchemaOrBool::Schema(l), SchemaOrBool::Schema(r)) => {
4289 SchemaOrBool::Schema(importer.merge_schemas(l, r, context)?)
4290 }
4291 (SchemaOrBool::Schema(s), SchemaOrBool::Bool(_))
4293 | (SchemaOrBool::Bool(_), SchemaOrBool::Schema(s)) => SchemaOrBool::Schema(s),
4294 (SchemaOrBool::Bool(_), SchemaOrBool::Bool(_)) => SchemaOrBool::default(),
4296 };
4297 left.insert(key, merged);
4298 } else {
4299 left.insert(key, value);
4300 }
4301 }
4302 Ok(left)
4303}
4304
4305#[cfg(test)]
4306mod tests {
4307 use super::*;
4308
4309 fn json_test_source(spec: &str) -> OpenApiSource {
4310 OpenApiSource::new(SourceFormat::Json, spec.to_owned())
4311 }
4312
4313 #[test]
4314 fn imports_minimal_openapi_document() {
4315 let spec = r##"
4316{
4317 "openapi": "3.1.0",
4318 "paths": {
4319 "/widgets/{widget_id}": {
4320 "get": {
4321 "operationId": "get_widget",
4322 "parameters": [
4323 {
4324 "name": "widget_id",
4325 "in": "path",
4326 "required": true,
4327 "schema": { "type": "string" }
4328 }
4329 ],
4330 "responses": {
4331 "200": {
4332 "description": "ok",
4333 "content": {
4334 "application/json": {
4335 "schema": { "$ref": "#/components/schemas/Widget" }
4336 }
4337 }
4338 }
4339 }
4340 }
4341 }
4342 },
4343 "components": {
4344 "schemas": {
4345 "Widget": {
4346 "type": "object",
4347 "required": ["id"],
4348 "properties": {
4349 "status": {
4350 "$ref": "#/components/schemas/WidgetStatus"
4351 },
4352 "id": { "type": "string" },
4353 "count": { "anyOf": [{ "type": "integer" }, { "type": "null" }] },
4354 "labels": {
4355 "type": "object",
4356 "additionalProperties": { "type": "string" }
4357 },
4358 "metadata": {
4359 "type": "object",
4360 "additionalProperties": true
4361 }
4362 }
4363 },
4364 "WidgetStatus": {
4365 "type": "string",
4366 "enum": ["READY", "PAUSED"]
4367 }
4368 }
4369 }
4370}
4371"##;
4372
4373 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4374 let result = OpenApiImporter::new(
4375 document,
4376 json_test_source(spec),
4377 LoadOpenApiOptions::default(),
4378 )
4379 .build_ir()
4380 .expect("should import successfully");
4381 let ir = result.ir;
4382
4383 assert_eq!(ir.models.len(), 2);
4384 assert_eq!(ir.operations.len(), 1);
4385 assert_eq!(ir.operations[0].name, "get_widget");
4386 assert!(ir.models.iter().any(|model| model.name == "Widget"));
4387 assert!(ir.models.iter().any(|model| model.name == "WidgetStatus"));
4388 let widget = ir
4389 .models
4390 .iter()
4391 .find(|model| model.name == "Widget")
4392 .expect("widget model");
4393 assert!(
4394 widget
4395 .fields
4396 .iter()
4397 .find(|field| field.name == "count")
4398 .expect("count field")
4399 .nullable
4400 );
4401 assert!(matches!(
4402 widget
4403 .fields
4404 .iter()
4405 .find(|field| field.name == "metadata")
4406 .expect("metadata field")
4407 .type_ref,
4408 TypeRef::Map { .. }
4409 ));
4410 assert_eq!(
4411 widget
4412 .fields
4413 .iter()
4414 .find(|field| field.name == "status")
4415 .expect("status field")
4416 .type_ref,
4417 TypeRef::named("WidgetStatus")
4418 );
4419 }
4420
4421 #[test]
4422 fn supports_parameter_refs() {
4423 let spec = r##"
4424{
4425 "openapi": "3.1.0",
4426 "paths": {
4427 "/key/{PK}": {
4428 "delete": {
4429 "operationId": "delete_key",
4430 "parameters": [
4431 { "$ref": "#/components/parameters/PK" }
4432 ],
4433 "responses": {
4434 "204": { "description": "deleted" }
4435 }
4436 }
4437 }
4438 },
4439 "components": {
4440 "parameters": {
4441 "PK": {
4442 "name": "PK",
4443 "in": "path",
4444 "required": true,
4445 "schema": { "type": "string" }
4446 }
4447 }
4448 }
4449}
4450"##;
4451
4452 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4453 let result = OpenApiImporter::new(
4454 document,
4455 json_test_source(spec),
4456 LoadOpenApiOptions::default(),
4457 )
4458 .build_ir()
4459 .expect("parameter refs should be supported");
4460
4461 assert_eq!(result.ir.operations.len(), 1);
4462 let operation = &result.ir.operations[0];
4463 assert_eq!(operation.params.len(), 1);
4464 let param = &operation.params[0];
4465 assert_eq!(param.name, "PK");
4466 assert_eq!(param.location, ParameterLocation::Path);
4467 assert!(param.required);
4468 assert_eq!(param.type_ref, TypeRef::primitive("string"));
4469 }
4470
4471 #[test]
4472 fn supports_swagger_root_parameter_refs_with_type() {
4473 let spec = r##"
4474{
4475 "swagger": "2.0",
4476 "paths": {
4477 "/widgets/{id}": {
4478 "get": {
4479 "operationId": "get_widget",
4480 "parameters": [
4481 { "$ref": "#/parameters/ApiVersionParameter" },
4482 { "$ref": "#/parameters/IdParameter" }
4483 ],
4484 "responses": {
4485 "200": { "description": "ok" }
4486 }
4487 }
4488 }
4489 },
4490 "parameters": {
4491 "ApiVersionParameter": {
4492 "name": "api-version",
4493 "in": "query",
4494 "required": true,
4495 "type": "string"
4496 },
4497 "IdParameter": {
4498 "name": "id",
4499 "in": "path",
4500 "required": true,
4501 "type": "integer",
4502 "format": "int64"
4503 }
4504 }
4505}
4506"##;
4507
4508 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4509 let result = OpenApiImporter::new(
4510 document,
4511 json_test_source(spec),
4512 LoadOpenApiOptions::default(),
4513 )
4514 .build_ir()
4515 .expect("swagger root parameter refs should be supported");
4516
4517 let operation = result
4518 .ir
4519 .operations
4520 .iter()
4521 .find(|operation| operation.name == "get_widget")
4522 .expect("operation should exist");
4523 assert_eq!(operation.params.len(), 2);
4524 assert_eq!(operation.params[0].name, "api-version");
4525 assert_eq!(operation.params[0].location, ParameterLocation::Query);
4526 assert_eq!(operation.params[0].type_ref, TypeRef::primitive("string"));
4527 assert_eq!(operation.params[1].name, "id");
4528 assert_eq!(operation.params[1].location, ParameterLocation::Path);
4529 assert_eq!(operation.params[1].type_ref, TypeRef::primitive("integer"));
4530 }
4531
4532 #[test]
4533 fn supports_references_into_parameter_schemas() {
4534 let spec = r##"
4535{
4536 "openapi": "3.1.0",
4537 "paths": {},
4538 "components": {
4539 "schemas": {
4540 "Widget": {
4541 "type": "object",
4542 "properties": {
4543 "companyId": {
4544 "$ref": "#/components/parameters/companyId/schema"
4545 }
4546 }
4547 }
4548 },
4549 "parameters": {
4550 "companyId": {
4551 "name": "companyId",
4552 "in": "path",
4553 "required": true,
4554 "schema": {
4555 "type": "string"
4556 }
4557 }
4558 }
4559 }
4560}
4561"##;
4562
4563 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
4564 let result = OpenApiImporter::new(
4565 document,
4566 json_test_source(spec),
4567 LoadOpenApiOptions::default(),
4568 )
4569 .build_ir()
4570 .expect("schema refs into reusable parameters should resolve");
4571
4572 let widget = result
4573 .ir
4574 .models
4575 .iter()
4576 .find(|model| model.name == "Widget")
4577 .expect("Widget model should exist");
4578 assert_eq!(widget.fields[0].name, "companyId");
4579 assert_eq!(widget.fields[0].type_ref, TypeRef::primitive("string"));
4580 }
4581
4582 #[test]
4583 fn preserves_external_file_references_as_named_types() {
4584 let spec = r##"
4585{
4586 "openapi": "3.1.0",
4587 "paths": {},
4588 "components": {
4589 "schemas": {
4590 "Route": {
4591 "type": "object",
4592 "properties": {
4593 "subnet": {
4594 "$ref": "./virtualNetwork.json#/definitions/Subnet"
4595 }
4596 }
4597 }
4598 }
4599 }
4600}
4601"##;
4602
4603 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
4604 let result = OpenApiImporter::new(
4605 document,
4606 json_test_source(spec),
4607 LoadOpenApiOptions::default(),
4608 )
4609 .build_ir()
4610 .expect("external file refs should remain importable as named types");
4611
4612 let route = result
4613 .ir
4614 .models
4615 .iter()
4616 .find(|model| model.name == "Route")
4617 .expect("Route model should exist");
4618 assert_eq!(route.fields[0].name, "subnet");
4619 assert_eq!(route.fields[0].type_ref, TypeRef::named("Subnet"));
4620 }
4621
4622 #[test]
4623 fn normalizes_swagger_body_parameters_into_request_bodies() {
4624 let spec = r##"
4625{
4626 "swagger": "2.0",
4627 "consumes": ["application/json"],
4628 "paths": {
4629 "/widgets/{id}": {
4630 "patch": {
4631 "operationId": "patch_widget",
4632 "parameters": [
4633 {
4634 "name": "id",
4635 "in": "path",
4636 "required": true,
4637 "type": "string"
4638 },
4639 {
4640 "name": "widget",
4641 "in": "body",
4642 "required": true,
4643 "description": "Widget update payload.",
4644 "schema": {
4645 "type": "object",
4646 "properties": {
4647 "name": { "type": "string" }
4648 }
4649 }
4650 }
4651 ],
4652 "responses": {
4653 "200": { "description": "ok" }
4654 }
4655 }
4656 }
4657 }
4658}
4659"##;
4660
4661 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4662 let result = OpenApiImporter::new(
4663 document,
4664 json_test_source(spec),
4665 LoadOpenApiOptions::default(),
4666 )
4667 .build_ir()
4668 .expect("swagger body parameters should become request bodies");
4669
4670 let operation = result
4671 .ir
4672 .operations
4673 .iter()
4674 .find(|operation| operation.name == "patch_widget")
4675 .expect("operation should exist");
4676 assert_eq!(operation.params.len(), 1);
4677 assert_eq!(operation.params[0].name, "id");
4678
4679 let request_body = operation.request_body.as_ref().expect("request body");
4680 assert!(request_body.required);
4681 assert_eq!(request_body.media_type, "application/json");
4682 assert_eq!(
4683 request_body.attributes.get("description"),
4684 Some(&Value::String("Widget update payload.".into()))
4685 );
4686 assert!(matches!(request_body.type_ref, Some(TypeRef::Named { .. })));
4687 }
4688
4689 #[test]
4690 fn supports_request_body_refs() {
4691 let spec = r##"
4692{
4693 "openapi": "3.1.0",
4694 "paths": {
4695 "/events": {
4696 "post": {
4697 "operationId": "create_event",
4698 "requestBody": {
4699 "$ref": "#/components/requestBodies/EventRequest"
4700 },
4701 "responses": {
4702 "200": { "description": "ok" }
4703 }
4704 }
4705 }
4706 },
4707 "components": {
4708 "requestBodies": {
4709 "EventRequest": {
4710 "$ref": "#/components/requestBodies/BaseEventRequest"
4711 },
4712 "BaseEventRequest": {
4713 "required": true,
4714 "content": {
4715 "application/json": {
4716 "schema": { "type": "string" }
4717 }
4718 }
4719 }
4720 }
4721 }
4722}
4723"##;
4724
4725 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4726 let result = OpenApiImporter::new(
4727 document,
4728 json_test_source(spec),
4729 LoadOpenApiOptions::default(),
4730 )
4731 .build_ir()
4732 .expect("request body refs should be supported");
4733
4734 let operation = result
4735 .ir
4736 .operations
4737 .iter()
4738 .find(|operation| operation.name == "create_event")
4739 .expect("operation should exist");
4740 let request_body = operation.request_body.as_ref().expect("request body");
4741 assert!(request_body.required);
4742 assert_eq!(request_body.media_type, "application/json");
4743 assert_eq!(request_body.type_ref, Some(TypeRef::primitive("string")));
4744 assert!(result.warnings.is_empty());
4745 }
4746
4747 #[test]
4748 fn defaults_empty_request_body_content_to_untyped_octet_stream() {
4749 let spec = r##"
4750{
4751 "openapi": "3.1.0",
4752 "paths": {
4753 "/events": {
4754 "post": {
4755 "operationId": "create_event",
4756 "requestBody": {
4757 "required": true,
4758 "content": {}
4759 },
4760 "responses": {
4761 "200": { "description": "ok" }
4762 }
4763 }
4764 }
4765 }
4766}
4767"##;
4768
4769 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4770 let result = OpenApiImporter::new(
4771 document,
4772 json_test_source(spec),
4773 LoadOpenApiOptions::default(),
4774 )
4775 .build_ir()
4776 .expect("empty request body content should be normalized");
4777
4778 let operation = result
4779 .ir
4780 .operations
4781 .iter()
4782 .find(|operation| operation.name == "create_event")
4783 .expect("operation should exist");
4784 let request_body = operation.request_body.as_ref().expect("request body");
4785 assert!(request_body.required);
4786 assert_eq!(request_body.media_type, "application/octet-stream");
4787 assert_eq!(request_body.type_ref, None);
4788 assert_eq!(result.warnings.len(), 1);
4789 assert!(matches!(
4790 result.warnings[0].kind,
4791 DiagnosticKind::EmptyRequestBodyContent
4792 ));
4793 assert_eq!(
4794 result.warnings[0].pointer.as_deref(),
4795 Some("#/paths/~1events/post/requestBody/content")
4796 );
4797 }
4798
4799 #[test]
4800 fn supports_const_scalar_fields() {
4801 let spec = r##"
4802{
4803 "openapi": "3.1.0",
4804 "paths": {},
4805 "components": {
4806 "schemas": {
4807 "PatchOp": {
4808 "type": "object",
4809 "properties": {
4810 "op": {
4811 "type": "string",
4812 "const": "replace"
4813 }
4814 }
4815 }
4816 }
4817 }
4818}
4819"##;
4820
4821 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4822 let result = OpenApiImporter::new(
4823 document,
4824 json_test_source(spec),
4825 LoadOpenApiOptions::default(),
4826 )
4827 .build_ir()
4828 .expect("const should be supported");
4829 let patch_op = result
4830 .ir
4831 .models
4832 .iter()
4833 .find(|model| model.name == "PatchOp")
4834 .expect("PatchOp model");
4835 assert!(
4836 patch_op
4837 .fields
4838 .iter()
4839 .any(|field| field.name == "op" && field.type_ref == TypeRef::primitive("string"))
4840 );
4841 assert!(result.warnings.is_empty());
4842 }
4843
4844 #[test]
4845 fn supports_type_array_with_nullability() {
4846 let spec = r##"
4847{
4848 "openapi": "3.1.0",
4849 "paths": {},
4850 "components": {
4851 "schemas": {
4852 "Widget": {
4853 "type": "object",
4854 "properties": {
4855 "name": {
4856 "type": ["string", "null"]
4857 }
4858 }
4859 }
4860 }
4861 }
4862}
4863"##;
4864
4865 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4866 let result = OpenApiImporter::new(
4867 document,
4868 json_test_source(spec),
4869 LoadOpenApiOptions::default(),
4870 )
4871 .build_ir()
4872 .expect("type arrays with null should be supported");
4873 let widget = result
4874 .ir
4875 .models
4876 .iter()
4877 .find(|model| model.name == "Widget")
4878 .expect("Widget model");
4879 let name = widget
4880 .fields
4881 .iter()
4882 .find(|field| field.name == "name")
4883 .expect("name field");
4884 assert_eq!(name.type_ref, TypeRef::primitive("string"));
4885 assert!(name.nullable);
4886 }
4887
4888 #[test]
4889 fn falls_back_when_operation_id_is_empty() {
4890 let spec = r##"
4891{
4892 "openapi": "3.1.0",
4893 "paths": {
4894 "/widgets": {
4895 "get": {
4896 "operationId": "",
4897 "responses": {
4898 "200": { "description": "ok" }
4899 }
4900 }
4901 }
4902 }
4903}
4904"##;
4905
4906 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4907 let result = OpenApiImporter::new(
4908 document,
4909 json_test_source(spec),
4910 LoadOpenApiOptions::default(),
4911 )
4912 .build_ir()
4913 .expect("empty operation ids should fall back");
4914 let operation = &result.ir.operations[0];
4915 assert_eq!(operation.name, "get_widgets");
4916 }
4917
4918 #[test]
4919 fn supports_implicit_enum_and_items_schema_shapes() {
4920 let spec = r##"
4921{
4922 "openapi": "3.1.0",
4923 "paths": {},
4924 "components": {
4925 "schemas": {
4926 "Widget": {
4927 "type": "object",
4928 "properties": {
4929 "status": {
4930 "enum": ["ready", "pending"]
4931 },
4932 "children": {
4933 "items": {
4934 "type": "string"
4935 }
4936 },
4937 "withTrial": {
4938 "format": "boolean"
4939 }
4940 }
4941 }
4942 }
4943 }
4944}
4945"##;
4946
4947 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4948 let result = OpenApiImporter::new(
4949 document,
4950 json_test_source(spec),
4951 LoadOpenApiOptions::default(),
4952 )
4953 .build_ir()
4954 .expect("implicit enum/items/format schema shapes should be supported");
4955 let widget = result
4956 .ir
4957 .models
4958 .iter()
4959 .find(|model| model.name == "Widget")
4960 .expect("Widget model");
4961 let status = widget
4962 .fields
4963 .iter()
4964 .find(|field| field.name == "status")
4965 .expect("status field");
4966 assert_eq!(status.type_ref, TypeRef::primitive("string"));
4967
4968 let children = widget
4969 .fields
4970 .iter()
4971 .find(|field| field.name == "children")
4972 .expect("children field");
4973 assert_eq!(
4974 children.type_ref,
4975 TypeRef::array(TypeRef::primitive("string"))
4976 );
4977
4978 let with_trial = widget
4979 .fields
4980 .iter()
4981 .find(|field| field.name == "withTrial")
4982 .expect("withTrial field");
4983 assert_eq!(with_trial.type_ref, TypeRef::primitive("boolean"));
4984 }
4985
4986 #[test]
4987 fn supports_object_schemas_with_validation_only_any_of() {
4988 let spec = r##"
4989{
4990 "openapi": "3.1.0",
4991 "paths": {},
4992 "components": {
4993 "schemas": {
4994 "PatchGist": {
4995 "type": "object",
4996 "properties": {
4997 "description": { "type": "string" },
4998 "files": { "type": "object" }
4999 },
5000 "anyOf": [
5001 { "required": ["description"] },
5002 { "required": ["files"] }
5003 ],
5004 "nullable": true
5005 }
5006 }
5007 }
5008}
5009"##;
5010
5011 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5012 let result = OpenApiImporter::new(
5013 document,
5014 json_test_source(spec),
5015 LoadOpenApiOptions::default(),
5016 )
5017 .build_ir()
5018 .expect("object schemas with validation-only anyOf should be supported");
5019 let patch_gist = result
5020 .ir
5021 .models
5022 .iter()
5023 .find(|model| model.name == "PatchGist")
5024 .expect("PatchGist model");
5025 let field_names = patch_gist
5026 .fields
5027 .iter()
5028 .map(|field| field.name.as_str())
5029 .collect::<Vec<_>>();
5030 assert_eq!(field_names, vec!["description", "files"]);
5031 }
5032
5033 #[test]
5034 fn preserves_schema_property_order() {
5035 let spec = r##"
5036{
5037 "openapi": "3.1.0",
5038 "paths": {},
5039 "components": {
5040 "schemas": {
5041 "Widget": {
5042 "type": "object",
5043 "properties": {
5044 "zebra": { "type": "string" },
5045 "alpha": { "type": "string" },
5046 "middle": { "type": "string" }
5047 }
5048 }
5049 }
5050 }
5051}
5052"##;
5053
5054 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5055 let result = OpenApiImporter::new(
5056 document,
5057 json_test_source(spec),
5058 LoadOpenApiOptions::default(),
5059 )
5060 .build_ir()
5061 .expect("property order should be preserved");
5062 let widget = result
5063 .ir
5064 .models
5065 .iter()
5066 .find(|model| model.name == "Widget")
5067 .expect("Widget model");
5068 let field_names = widget
5069 .fields
5070 .iter()
5071 .map(|field| field.name.as_str())
5072 .collect::<Vec<_>>();
5073 assert_eq!(field_names, vec!["zebra", "alpha", "middle"]);
5074 }
5075
5076 #[test]
5077 fn supports_metadata_only_property_schema_as_any() {
5078 let spec = r##"
5079{
5080 "openapi": "3.1.0",
5081 "paths": {},
5082 "components": {
5083 "schemas": {
5084 "ErrorDetail": {
5085 "type": "object",
5086 "properties": {
5087 "value": {
5088 "description": "The value at the given location"
5089 }
5090 }
5091 }
5092 }
5093 }
5094}
5095"##;
5096
5097 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5098 let result = OpenApiImporter::new(
5099 document,
5100 json_test_source(spec),
5101 LoadOpenApiOptions::default(),
5102 )
5103 .build_ir()
5104 .expect("metadata-only schema should be treated as any");
5105 let error_detail = result
5106 .ir
5107 .models
5108 .iter()
5109 .find(|model| model.name == "ErrorDetail")
5110 .expect("ErrorDetail model");
5111 let value = error_detail
5112 .fields
5113 .iter()
5114 .find(|field| field.name == "value")
5115 .expect("value field");
5116 assert_eq!(value.type_ref, TypeRef::primitive("any"));
5117 }
5118
5119 #[test]
5120 fn supports_discriminator_on_unions() {
5121 let spec = r##"
5122{
5123 "openapi": "3.1.0",
5124 "paths": {},
5125 "components": {
5126 "schemas": {
5127 "AddOperation": {
5128 "type": "object",
5129 "properties": {
5130 "op": {
5131 "type": "string",
5132 "const": "add"
5133 }
5134 }
5135 },
5136 "RemoveOperation": {
5137 "type": "object",
5138 "properties": {
5139 "op": {
5140 "type": "string",
5141 "const": "remove"
5142 }
5143 }
5144 },
5145 "PatchSchema": {
5146 "type": "object",
5147 "properties": {
5148 "patches": {
5149 "type": "array",
5150 "items": {
5151 "oneOf": [
5152 { "$ref": "#/components/schemas/AddOperation" },
5153 { "$ref": "#/components/schemas/RemoveOperation" }
5154 ],
5155 "discriminator": {
5156 "propertyName": "op",
5157 "mapping": {
5158 "add": "#/components/schemas/AddOperation",
5159 "remove": "#/components/schemas/RemoveOperation"
5160 }
5161 }
5162 }
5163 }
5164 }
5165 }
5166 }
5167 }
5168}
5169"##;
5170
5171 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5172 let result = OpenApiImporter::new(
5173 document,
5174 json_test_source(spec),
5175 LoadOpenApiOptions::default(),
5176 )
5177 .build_ir()
5178 .expect("discriminator unions should be supported");
5179 let patch_schema = result
5180 .ir
5181 .models
5182 .iter()
5183 .find(|model| model.name == "PatchSchema")
5184 .expect("PatchSchema model");
5185 let patches = patch_schema
5186 .fields
5187 .iter()
5188 .find(|field| field.name == "patches")
5189 .expect("patches field");
5190 assert!(matches!(
5191 &patches.type_ref,
5192 TypeRef::Array { item }
5193 if matches!(
5194 item.as_ref(),
5195 TypeRef::Union { variants }
5196 if variants == &vec![
5197 TypeRef::named("AddOperation"),
5198 TypeRef::named("RemoveOperation")
5199 ]
5200 )
5201 ));
5202 assert!(result.warnings.is_empty());
5203 }
5204
5205 #[test]
5206 fn supports_all_of_object_composition() {
5207 let spec = r##"
5208{
5209 "openapi": "3.1.0",
5210 "paths": {},
5211 "components": {
5212 "schemas": {
5213 "Cursor": {
5214 "type": "object",
5215 "properties": {
5216 "cursor": { "type": "string" }
5217 },
5218 "required": ["cursor"]
5219 },
5220 "PatchSchema": {
5221 "allOf": [
5222 { "$ref": "#/components/schemas/Cursor" },
5223 {
5224 "type": "object",
5225 "properties": {
5226 "items": {
5227 "type": "array",
5228 "items": { "type": "string" }
5229 }
5230 },
5231 "required": ["items"]
5232 }
5233 ]
5234 },
5235 "BaseId": { "type": "string" },
5236 "WrappedId": {
5237 "allOf": [
5238 { "$ref": "#/components/schemas/BaseId" },
5239 { "description": "Identifier wrapper" }
5240 ]
5241 },
5242 "Status": {
5243 "type": "string",
5244 "enum": ["ready", "pending", "failed"]
5245 },
5246 "RetryableStatus": {
5247 "allOf": [
5248 { "$ref": "#/components/schemas/Status" },
5249 { "enum": ["pending", "failed"] }
5250 ]
5251 },
5252 "TitledCursor": {
5253 "allOf": [
5254 {
5255 "$ref": "#/components/schemas/Cursor",
5256 "title": "Cursor Base"
5257 },
5258 {
5259 "type": "object",
5260 "title": "Cursor Overlay",
5261 "properties": {
5262 "nextCursor": { "type": "string" }
5263 }
5264 }
5265 ]
5266 },
5267 "Wrapper": {
5268 "type": "object",
5269 "properties": {
5270 "cursorRef": {
5271 "allOf": [
5272 { "$ref": "#/components/schemas/Cursor" },
5273 { "description": "Keep the named component reference" }
5274 ]
5275 }
5276 }
5277 }
5278 }
5279 }
5280}
5281"##;
5282
5283 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5284 let result = OpenApiImporter::new(
5285 document,
5286 json_test_source(spec),
5287 LoadOpenApiOptions::default(),
5288 )
5289 .build_ir()
5290 .expect("allOf should be supported");
5291
5292 let patch_schema = result
5293 .ir
5294 .models
5295 .iter()
5296 .find(|model| model.name == "PatchSchema")
5297 .expect("PatchSchema model");
5298 let field_names = patch_schema
5299 .fields
5300 .iter()
5301 .map(|field| field.name.as_str())
5302 .collect::<Vec<_>>();
5303 assert_eq!(field_names, vec!["cursor", "items"]);
5304
5305 let titled_cursor = result
5306 .ir
5307 .models
5308 .iter()
5309 .find(|model| model.name == "TitledCursor")
5310 .expect("TitledCursor model");
5311 let titled_cursor_fields = titled_cursor
5312 .fields
5313 .iter()
5314 .map(|field| field.name.as_str())
5315 .collect::<Vec<_>>();
5316 assert_eq!(titled_cursor_fields, vec!["cursor", "nextCursor"]);
5317
5318 let retryable_status = result
5319 .ir
5320 .models
5321 .iter()
5322 .find(|model| model.name == "RetryableStatus")
5323 .expect("RetryableStatus model");
5324 assert_eq!(
5325 retryable_status.attributes.get("enum_values"),
5326 Some(&Value::Array(vec![
5327 Value::String("pending".into()),
5328 Value::String("failed".into())
5329 ]))
5330 );
5331 assert!(
5332 patch_schema
5333 .fields
5334 .iter()
5335 .find(|field| field.name == "cursor")
5336 .map(|field| !field.optional)
5337 .unwrap_or(false)
5338 );
5339 assert!(
5340 patch_schema
5341 .fields
5342 .iter()
5343 .find(|field| field.name == "items")
5344 .map(|field| !field.optional)
5345 .unwrap_or(false)
5346 );
5347
5348 let wrapped_id = result
5349 .ir
5350 .models
5351 .iter()
5352 .find(|model| model.name == "WrappedId")
5353 .expect("WrappedId model");
5354 assert_eq!(
5355 wrapped_id.attributes.get("alias_type_ref"),
5356 Some(&json!(TypeRef::primitive("string")))
5357 );
5358 let wrapper = result
5359 .ir
5360 .models
5361 .iter()
5362 .find(|model| model.name == "Wrapper")
5363 .expect("Wrapper model");
5364 assert_eq!(
5365 wrapper
5366 .fields
5367 .iter()
5368 .find(|field| field.name == "cursorRef")
5369 .map(|field| &field.type_ref),
5370 Some(&TypeRef::named("Cursor"))
5371 );
5372 assert!(result.warnings.is_empty());
5373 }
5374
5375 #[test]
5376 fn errors_on_recursive_all_of_reference_cycles() {
5377 let spec = r##"
5378{
5379 "openapi": "3.1.0",
5380 "paths": {},
5381 "components": {
5382 "schemas": {
5383 "Node": {
5384 "allOf": [
5385 { "$ref": "#/components/schemas/Node" }
5386 ]
5387 }
5388 }
5389 }
5390}
5391"##;
5392
5393 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5394 let error = OpenApiImporter::new(
5395 document,
5396 json_test_source(spec),
5397 LoadOpenApiOptions::default(),
5398 )
5399 .build_ir()
5400 .expect_err("recursive allOf cycles should fail cleanly");
5401
5402 let rendered = format!("{error:#}");
5403 assert!(
5404 rendered.contains("recursive reference cycle"),
5405 "unexpected error: {rendered}"
5406 );
5407 assert!(rendered.contains("#/components/schemas/Node"));
5408 }
5409
5410 #[test]
5411 fn errors_on_unhandled_elements_by_default_and_warns_when_ignored() {
5412 let spec = r##"
5415{
5416 "openapi": "3.1.0",
5417 "paths": {},
5418 "components": {
5419 "schemas": {
5420 "PatchSchema": {
5421 "if": {
5422 "properties": { "foo": { "type": "string" } }
5423 }
5424 }
5425 }
5426 }
5427}
5428"##;
5429
5430 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5431 let strict_error = OpenApiImporter::new(
5432 document.clone(),
5433 json_test_source(spec),
5434 LoadOpenApiOptions::default(),
5435 )
5436 .build_ir()
5437 .expect_err("strict mode should fail");
5438 assert!(
5439 strict_error
5440 .to_string()
5441 .contains("`if` is not supported yet")
5442 );
5443
5444 let warning_result = OpenApiImporter::new(
5445 document,
5446 json_test_source(spec),
5447 LoadOpenApiOptions {
5448 ignore_unhandled: true,
5449 ..Default::default()
5450 },
5451 )
5452 .build_ir()
5453 .expect("ignore mode should succeed");
5454 assert!(
5455 warning_result
5456 .warnings
5457 .iter()
5458 .any(|warning| matches!(&warning.kind, DiagnosticKind::UnsupportedSchemaKeyword { keyword } if keyword == "if"))
5459 );
5460
5461 let not_spec = r##"
5463{
5464 "openapi": "3.1.0",
5465 "paths": {},
5466 "components": {
5467 "schemas": {
5468 "NotSchema": {
5469 "not": { "type": "object" }
5470 }
5471 }
5472 }
5473}
5474"##;
5475 let not_document: OpenApiDocument =
5476 serde_json::from_str(not_spec).expect("valid test spec");
5477 let not_result = OpenApiImporter::new(
5478 not_document,
5479 json_test_source(not_spec),
5480 LoadOpenApiOptions::default(),
5481 )
5482 .build_ir()
5483 .expect("`not` keyword should be silently ignored");
5484 assert!(
5485 not_result.warnings.is_empty(),
5486 "`not` should produce no warnings"
5487 );
5488 }
5489
5490 #[test]
5491 fn errors_on_unknown_schema_keywords() {
5492 let spec = r##"
5493{
5494 "openapi": "3.1.0",
5495 "paths": {},
5496 "components": {
5497 "schemas": {
5498 "PatchSchema": {
5499 "type": "string",
5500 "frobnicate": true
5501 }
5502 }
5503 }
5504}
5505"##;
5506
5507 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5508 let error = OpenApiImporter::new(
5509 document,
5510 json_test_source(spec),
5511 LoadOpenApiOptions::default(),
5512 )
5513 .build_ir()
5514 .expect_err("unknown keyword should fail");
5515 assert!(
5516 error
5517 .to_string()
5518 .contains("unknown schema keyword `frobnicate`")
5519 );
5520 }
5521
5522 #[test]
5523 fn ignores_known_non_codegen_schema_keywords() {
5524 let spec = r##"
5525{
5526 "openapi": "3.1.0",
5527 "paths": {},
5528 "components": {
5529 "schemas": {
5530 "PatchSchema": {
5531 "type": "string",
5532 "description": "some text",
5533 "default": "value",
5534 "minLength": 1,
5535 "contentEncoding": "base64",
5536 "externalDocs": {
5537 "description": "More details",
5538 "url": "https://example.com/schema-docs"
5539 },
5540 "xml": {
5541 "name": "patchSchema"
5542 }
5543 }
5544 }
5545 }
5546}
5547"##;
5548
5549 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5550 let result = OpenApiImporter::new(
5551 document,
5552 json_test_source(spec),
5553 LoadOpenApiOptions::default(),
5554 )
5555 .build_ir()
5556 .expect("known ignored keywords should not fail");
5557 assert!(result.warnings.is_empty());
5558 }
5559
5560 #[test]
5561 fn json_parse_errors_include_schema_path_and_source_context() {
5562 let spec = r##"
5563{
5564 "openapi": "3.1.0",
5565 "paths": {},
5566 "components": {
5567 "schemas": {
5568 "Broken": {
5569 "type": "object",
5570 "title": ["not", "a", "string"]
5571 }
5572 }
5573 }
5574}
5575"##;
5576
5577 let error = parse_json_openapi_document(Path::new("broken.json"), spec)
5578 .expect_err("invalid schema shape should fail during deserialization");
5579 let message = error.to_string();
5580 assert!(message.contains("failed to parse JSON OpenAPI document `broken.json`"));
5581 assert!(message.contains("schema mismatch at `components.schemas.Broken.title`"));
5582 assert!(message.contains("invalid type"));
5583 assert!(message.contains("source: \"title\": [\"not\", \"a\", \"string\"]"));
5584 assert!(message.contains("note: this usually means"));
5585 }
5586
5587 #[test]
5588 fn yaml_loader_ignores_tab_only_blank_lines_in_block_scalars() {
5589 let spec = r##"
5590openapi: 3.1.0
5591paths: {}
5592components:
5593 schemas:
5594 AdditionalDataAirline:
5595 type: object
5596 properties:
5597 airline.leg.date_of_travel:
5598 description: |-
5599
5600 Date and time of travel in ISO 8601 format.
5601 type: string
5602"##;
5603
5604 let loaded = parse_yaml_openapi_document(Path::new("broken.yaml"), spec)
5605 .expect("tab-only blank lines should be normalized before YAML parsing");
5606 let result = OpenApiImporter::new(
5607 loaded.document,
5608 loaded.source,
5609 LoadOpenApiOptions::default(),
5610 )
5611 .build_ir()
5612 .expect("normalized YAML should import");
5613
5614 let model = result
5615 .ir
5616 .models
5617 .iter()
5618 .find(|model| model.name == "AdditionalDataAirline")
5619 .expect("model should exist");
5620 assert!(
5621 model
5622 .fields
5623 .iter()
5624 .any(|field| field.name == "airline.leg.date_of_travel")
5625 );
5626 }
5627
5628 #[test]
5629 fn preserves_content_encoding_metadata_in_ir_attributes() {
5630 let spec = r##"
5631{
5632 "openapi": "3.1.0",
5633 "paths": {
5634 "/widgets": {
5635 "post": {
5636 "operationId": "create_widget",
5637 "parameters": [
5638 {
5639 "name": "token",
5640 "in": "query",
5641 "required": true,
5642 "schema": {
5643 "type": "string",
5644 "contentEncoding": "base64"
5645 }
5646 }
5647 ],
5648 "requestBody": {
5649 "required": true,
5650 "content": {
5651 "application/json": {
5652 "schema": {
5653 "type": "string",
5654 "contentEncoding": "base64",
5655 "contentMediaType": "application/octet-stream"
5656 }
5657 }
5658 }
5659 },
5660 "responses": {
5661 "200": {
5662 "description": "ok",
5663 "content": {
5664 "application/json": {
5665 "schema": {
5666 "$ref": "#/components/schemas/EncodedValue"
5667 }
5668 }
5669 }
5670 }
5671 }
5672 }
5673 }
5674 },
5675 "components": {
5676 "schemas": {
5677 "EncodedValue": {
5678 "type": "object",
5679 "properties": {
5680 "payload": {
5681 "type": "string",
5682 "contentEncoding": "base64"
5683 }
5684 }
5685 }
5686 }
5687 }
5688}
5689"##;
5690
5691 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5692 let result = OpenApiImporter::new(
5693 document,
5694 json_test_source(spec),
5695 LoadOpenApiOptions::default(),
5696 )
5697 .build_ir()
5698 .expect("content encoding metadata should be preserved");
5699
5700 let operation = result
5701 .ir
5702 .operations
5703 .iter()
5704 .find(|operation| operation.name == "create_widget")
5705 .expect("operation should exist");
5706 assert_eq!(
5707 operation.params[0]
5708 .attributes
5709 .get("content_encoding")
5710 .and_then(Value::as_str),
5711 Some("base64")
5712 );
5713 assert_eq!(
5714 operation
5715 .request_body
5716 .as_ref()
5717 .and_then(|request_body| request_body.attributes.get("content_media_type"))
5718 .and_then(Value::as_str),
5719 Some("application/octet-stream")
5720 );
5721 let response = operation
5722 .responses
5723 .iter()
5724 .find(|response| response.status == "200")
5725 .expect("response should exist");
5726 assert_eq!(
5727 response.type_ref.as_ref(),
5728 Some(&TypeRef::named("EncodedValue"))
5729 );
5730 let model = result
5731 .ir
5732 .models
5733 .iter()
5734 .find(|model| model.name == "EncodedValue")
5735 .expect("model should exist");
5736 assert_eq!(
5737 model.fields[0]
5738 .attributes
5739 .get("content_encoding")
5740 .and_then(Value::as_str),
5741 Some("base64")
5742 );
5743 }
5744
5745 #[test]
5746 fn supports_swagger_form_data_parameters() {
5747 let spec = r##"
5748{
5749 "swagger": "2.0",
5750 "consumes": ["application/x-www-form-urlencoded"],
5751 "paths": {
5752 "/widgets": {
5753 "post": {
5754 "operationId": "create_widget",
5755 "parameters": [
5756 { "$ref": "#/parameters/form_name" }
5757 ],
5758 "responses": {
5759 "200": {
5760 "description": "ok"
5761 }
5762 }
5763 }
5764 }
5765 },
5766 "parameters": {
5767 "form_name": {
5768 "name": "name",
5769 "in": "formData",
5770 "description": "Widget name",
5771 "required": true,
5772 "type": "string"
5773 }
5774 }
5775}
5776"##;
5777
5778 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid swagger spec");
5779 let result = OpenApiImporter::new(
5780 document,
5781 json_test_source(spec),
5782 LoadOpenApiOptions::default(),
5783 )
5784 .build_ir()
5785 .expect("formData parameters should normalize into a request body");
5786 let operation = result
5787 .ir
5788 .operations
5789 .iter()
5790 .find(|operation| operation.name == "create_widget")
5791 .expect("operation should exist");
5792 assert!(operation.params.is_empty());
5793 let request_body = operation
5794 .request_body
5795 .as_ref()
5796 .expect("formData should create a request body");
5797 assert_eq!(request_body.media_type, "application/x-www-form-urlencoded");
5798 assert_eq!(
5799 request_body.type_ref.as_ref(),
5800 Some(&TypeRef::named("CreateWidgetRequest"))
5801 );
5802 let body_model = result
5803 .ir
5804 .models
5805 .iter()
5806 .find(|model| model.name == "CreateWidgetRequest")
5807 .expect("inline form body model should exist");
5808 assert_eq!(body_model.fields[0].name, "name");
5809 assert!(!body_model.fields[0].optional);
5810 assert_eq!(
5811 body_model.fields[0]
5812 .attributes
5813 .get("description")
5814 .and_then(Value::as_str),
5815 Some("Widget name")
5816 );
5817 }
5818
5819 #[test]
5820 fn supports_path_local_parameter_references() {
5821 let spec = r##"
5822{
5823 "swagger": "2.0",
5824 "definitions": {
5825 "Widget": {
5826 "type": "object",
5827 "properties": {
5828 "id": { "type": "string" }
5829 }
5830 }
5831 },
5832 "paths": {
5833 "/widgets/{id}": {
5834 "post": {
5835 "operationId": "get_widget",
5836 "parameters": [
5837 {
5838 "name": "id",
5839 "in": "path",
5840 "required": true,
5841 "type": "string"
5842 }
5843 ],
5844 "responses": {
5845 "200": {
5846 "description": "ok",
5847 "schema": {
5848 "$ref": "#/definitions/Widget"
5849 }
5850 }
5851 }
5852 }
5853 },
5854 "/widget-ids": {
5855 "get": {
5856 "operationId": "list_widget_ids",
5857 "parameters": [
5858 {
5859 "$ref": "#/paths/~1widgets~1%7Bid%7D/post/parameters/0"
5860 }
5861 ],
5862 "responses": {
5863 "200": {
5864 "description": "ok"
5865 }
5866 }
5867 }
5868 }
5869 }
5870}
5871"##;
5872
5873 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid swagger spec");
5874 let result = OpenApiImporter::new(
5875 document,
5876 json_test_source(spec),
5877 LoadOpenApiOptions::default(),
5878 )
5879 .build_ir()
5880 .expect("path-local parameter refs should resolve");
5881 let operation = result
5882 .ir
5883 .operations
5884 .iter()
5885 .find(|operation| operation.name == "list_widget_ids")
5886 .expect("operation should exist");
5887 assert_eq!(operation.params[0].name, "id");
5888 assert_eq!(operation.params[0].location, ParameterLocation::Path);
5889 assert_eq!(
5890 result
5891 .ir
5892 .models
5893 .iter()
5894 .find(|model| model.name == "Widget")
5895 .map(|model| model.name.as_str()),
5896 Some("Widget")
5897 );
5898 }
5899
5900 #[test]
5901 fn de_duplicates_operation_names() {
5902 let spec = r##"
5903{
5904 "openapi": "3.1.0",
5905 "paths": {
5906 "/widgets": {
5907 "get": {
5908 "operationId": "get_widgets",
5909 "responses": {
5910 "200": { "description": "ok" }
5911 }
5912 }
5913 },
5914 "/users": {
5915 "get": {
5916 "operationId": "get_widgets",
5917 "responses": {
5918 "200": { "description": "ok" }
5919 }
5920 }
5921 }
5922 }
5923}
5924"##;
5925
5926 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
5927 let result = OpenApiImporter::new(
5928 document,
5929 json_test_source(spec),
5930 LoadOpenApiOptions::default(),
5931 )
5932 .build_ir()
5933 .expect("duplicate operation ids should be disambiguated");
5934 let names = result
5935 .ir
5936 .operations
5937 .iter()
5938 .map(|operation| operation.name.as_str())
5939 .collect::<Vec<_>>();
5940 assert_eq!(names, vec!["get_widgets", "get_widgets_2"]);
5941 }
5942
5943 #[test]
5944 fn supports_numeric_all_of_type_widening() {
5945 let spec = r##"
5946{
5947 "openapi": "3.1.0",
5948 "paths": {},
5949 "components": {
5950 "schemas": {
5951 "WidgetEvent": {
5952 "type": "object",
5953 "properties": {
5954 "payload": {
5955 "allOf": [
5956 {
5957 "type": "object",
5958 "properties": {
5959 "count": { "type": "integer" }
5960 }
5961 },
5962 {
5963 "type": "object",
5964 "properties": {
5965 "count": { "type": "number" }
5966 }
5967 }
5968 ]
5969 }
5970 }
5971 }
5972 }
5973 }
5974}
5975"##;
5976
5977 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
5978 let result = OpenApiImporter::new(
5979 document,
5980 json_test_source(spec),
5981 LoadOpenApiOptions::default(),
5982 )
5983 .build_ir()
5984 .expect("numeric allOf overlays should merge");
5985 assert!(result.warnings.is_empty());
5986 let payload_model = result
5987 .ir
5988 .models
5989 .iter()
5990 .find(|model| model.name == "WidgetEventPayload")
5991 .expect("inline payload model should exist");
5992 assert_eq!(
5993 payload_model.fields[0].type_ref,
5994 TypeRef::primitive("number")
5995 );
5996 }
5997
5998 #[test]
5999 fn supports_nested_schema_definitions_references() {
6000 let spec = r##"
6001{
6002 "openapi": "3.1.0",
6003 "paths": {},
6004 "components": {
6005 "schemas": {
6006 "Transfer": {
6007 "type": "object",
6008 "definitions": {
6009 "money": {
6010 "type": "object",
6011 "properties": {
6012 "currency": { "type": "string" }
6013 }
6014 }
6015 },
6016 "properties": {
6017 "amount": {
6018 "$ref": "#/components/schemas/Transfer/definitions/money"
6019 },
6020 "currency": {
6021 "$ref": "#/components/schemas/Transfer/definitions/money/properties/currency"
6022 }
6023 }
6024 }
6025 }
6026 }
6027}
6028"##;
6029
6030 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6031 let result = OpenApiImporter::new(
6032 document,
6033 json_test_source(spec),
6034 LoadOpenApiOptions::default(),
6035 )
6036 .build_ir()
6037 .expect("nested schema definitions refs should resolve");
6038 let transfer = result
6039 .ir
6040 .models
6041 .iter()
6042 .find(|model| model.name == "Transfer")
6043 .expect("Transfer model should exist");
6044 assert_eq!(transfer.fields[0].name, "amount");
6045 let amount_type_name = match &transfer.fields[0].type_ref {
6046 TypeRef::Named { name } => name.clone(),
6047 other => panic!("expected named type for nested definition, got {other:?}"),
6048 };
6049 assert!(
6050 amount_type_name.starts_with("TransferAmount"),
6051 "nested definition should be materialized as a TransferAmount* inline model"
6052 );
6053 assert!(
6054 result
6055 .ir
6056 .models
6057 .iter()
6058 .any(|model| model.name == amount_type_name),
6059 "nested local definition model should be imported"
6060 );
6061 assert_eq!(transfer.fields[1].type_ref, TypeRef::primitive("string"));
6062 }
6063
6064 #[test]
6065 fn supports_nullable_all_of_overlays_on_referenced_scalars() {
6066 let spec = r##"
6067{
6068 "openapi": "3.1.0",
6069 "paths": {},
6070 "components": {
6071 "schemas": {
6072 "Transfer": {
6073 "type": "object",
6074 "definitions": {
6075 "money": {
6076 "type": "string"
6077 }
6078 }
6079 },
6080 "Bill": {
6081 "type": "object",
6082 "properties": {
6083 "currency": {
6084 "allOf": [
6085 { "$ref": "#/components/schemas/Transfer/definitions/money" },
6086 { "type": "null" }
6087 ]
6088 }
6089 }
6090 }
6091 }
6092 }
6093}
6094"##;
6095
6096 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6097 let result = OpenApiImporter::new(
6098 document,
6099 json_test_source(spec),
6100 LoadOpenApiOptions::default(),
6101 )
6102 .build_ir()
6103 .expect("nullable allOf overlay should merge");
6104 let bill = result
6105 .ir
6106 .models
6107 .iter()
6108 .find(|model| model.name == "Bill")
6109 .expect("Bill model should exist");
6110 assert_eq!(bill.fields[0].name, "currency");
6111 assert_eq!(bill.fields[0].type_ref, TypeRef::primitive("string"));
6112 assert!(bill.fields[0].nullable);
6113 }
6114
6115 #[test]
6116 fn supports_recursive_local_object_references_without_unbounded_inline_models() {
6117 let spec = r##"
6118{
6119 "openapi": "3.1.0",
6120 "paths": {},
6121 "components": {
6122 "schemas": {
6123 "PushOption": {
6124 "definitions": {
6125 "pushOptionProperty": {
6126 "type": "object",
6127 "properties": {
6128 "properties": {
6129 "type": "object",
6130 "additionalProperties": {
6131 "$ref": "#/components/schemas/PushOption/definitions/pushOptionProperty"
6132 }
6133 }
6134 }
6135 }
6136 },
6137 "type": "object",
6138 "properties": {
6139 "properties": {
6140 "type": "object",
6141 "additionalProperties": {
6142 "$ref": "#/components/schemas/PushOption/definitions/pushOptionProperty"
6143 }
6144 }
6145 }
6146 }
6147 }
6148 }
6149}
6150"##;
6151
6152 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6153 let result = OpenApiImporter::new(
6154 document,
6155 json_test_source(spec),
6156 LoadOpenApiOptions::default(),
6157 )
6158 .build_ir()
6159 .expect("recursive local refs should not recurse forever");
6160
6161 let push_option = result
6162 .ir
6163 .models
6164 .iter()
6165 .find(|model| model.name == "PushOption")
6166 .expect("PushOption model should exist");
6167 let properties_field = push_option
6168 .fields
6169 .iter()
6170 .find(|field| field.name == "properties")
6171 .expect("properties field should exist");
6172 assert!(matches!(properties_field.type_ref, TypeRef::Map { .. }));
6173
6174 let inline_models = result
6175 .ir
6176 .models
6177 .iter()
6178 .filter(|model| model.name.contains("Properties"))
6179 .collect::<Vec<_>>();
6180 assert!(
6181 inline_models.len() <= 2,
6182 "recursive local refs should reuse an inline model instead of generating an unbounded chain"
6183 );
6184 }
6185
6186 #[test]
6187 fn supports_collection_format_metadata() {
6188 let spec = r##"
6189{
6190 "swagger": "2.0",
6191 "paths": {
6192 "/widgets": {
6193 "get": {
6194 "operationId": "list_widgets",
6195 "parameters": [
6196 {
6197 "name": "categories",
6198 "in": "query",
6199 "type": "array",
6200 "collectionFormat": "csv",
6201 "items": {
6202 "type": "string",
6203 "collectionFormat": "csv"
6204 }
6205 }
6206 ],
6207 "responses": {
6208 "200": {
6209 "description": "ok"
6210 }
6211 }
6212 }
6213 }
6214 }
6215}
6216"##;
6217
6218 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6219 let result = OpenApiImporter::new(
6220 document,
6221 json_test_source(spec),
6222 LoadOpenApiOptions::default(),
6223 )
6224 .build_ir()
6225 .expect("collectionFormat should be accepted");
6226 let operation = result
6227 .ir
6228 .operations
6229 .iter()
6230 .find(|operation| operation.name == "list_widgets")
6231 .expect("operation should exist");
6232 assert_eq!(
6233 operation.params[0]
6234 .attributes
6235 .get("collection_format")
6236 .and_then(Value::as_str),
6237 Some("csv")
6238 );
6239 }
6240
6241 #[test]
6242 fn supports_all_of_with_multiple_discriminators() {
6243 let spec = r##"
6244{
6245 "openapi": "3.1.0",
6246 "paths": {},
6247 "components": {
6248 "schemas": {
6249 "Base": {
6250 "type": "object",
6251 "discriminator": {
6252 "propertyName": "serviceType"
6253 },
6254 "properties": {
6255 "serviceType": { "type": "string" }
6256 }
6257 },
6258 "Derived": {
6259 "allOf": [
6260 { "$ref": "#/components/schemas/Base" },
6261 {
6262 "type": "object",
6263 "discriminator": {
6264 "propertyName": "credentialType"
6265 },
6266 "properties": {
6267 "credentialType": { "type": "string" }
6268 }
6269 }
6270 ]
6271 }
6272 }
6273 }
6274}
6275"##;
6276
6277 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6278 let result = OpenApiImporter::new(
6279 document,
6280 json_test_source(spec),
6281 LoadOpenApiOptions::default(),
6282 )
6283 .build_ir()
6284 .expect("allOf discriminator metadata should not fail");
6285 assert!(result.warnings.is_empty());
6286 }
6287
6288 #[test]
6289 fn empty_property_names_fail_cleanly_or_warn_when_ignored() {
6290 let spec = r##"
6291{
6292 "openapi": "3.1.0",
6293 "paths": {},
6294 "components": {
6295 "schemas": {
6296 "Broken": {
6297 "type": "object",
6298 "properties": {
6299 "": { "type": "string" }
6300 }
6301 }
6302 }
6303 }
6304}
6305"##;
6306
6307 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6308 let error = OpenApiImporter::new(
6309 document.clone(),
6310 json_test_source(spec),
6311 LoadOpenApiOptions::default(),
6312 )
6313 .build_ir()
6314 .expect_err("empty property names should fail by default");
6315 assert!(error.to_string().contains("property #1 has an empty name"));
6316
6317 let result = OpenApiImporter::new(
6318 document,
6319 json_test_source(spec),
6320 LoadOpenApiOptions {
6321 ignore_unhandled: true,
6322 emit_timings: false,
6323 },
6324 )
6325 .build_ir()
6326 .expect("empty property names should be synthesized when warnings are allowed");
6327 let broken = result
6328 .ir
6329 .models
6330 .iter()
6331 .find(|model| model.name == "Broken")
6332 .expect("Broken model should exist");
6333 assert_eq!(broken.fields[0].name, "unnamed_field_1");
6334 }
6335
6336 #[test]
6337 fn supports_ref_to_components_responses() {
6338 let spec = r##"
6339{
6340 "openapi": "3.1.0",
6341 "paths": {
6342 "/widgets": {
6343 "get": {
6344 "operationId": "list_widgets",
6345 "parameters": [],
6346 "responses": {
6347 "200": {
6348 "description": "ok",
6349 "content": {
6350 "application/json": {
6351 "schema": { "$ref": "#/components/responses/WidgetList/content/application~1json/schema" }
6352 }
6353 }
6354 }
6355 }
6356 }
6357 }
6358 },
6359 "components": {
6360 "responses": {
6361 "WidgetList": {
6362 "description": "A list of widgets",
6363 "content": {
6364 "application/json": {
6365 "schema": {
6366 "type": "array",
6367 "items": { "type": "string" }
6368 }
6369 }
6370 }
6371 }
6372 }
6373 }
6374}
6375"##;
6376 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6377 let result = OpenApiImporter::new(
6378 document,
6379 json_test_source(spec),
6380 LoadOpenApiOptions::default(),
6381 )
6382 .build_ir()
6383 .expect("$ref to components/responses should succeed");
6384 let op = result
6385 .ir
6386 .operations
6387 .iter()
6388 .find(|o| o.name == "list_widgets")
6389 .expect("op should exist");
6390 let response = op.responses.first().expect("should have a response");
6391 assert!(
6393 response.type_ref.is_some(),
6394 "response type_ref should be resolved, got: {response:?}"
6395 );
6396 }
6397
6398 #[test]
6399 fn supports_ref_to_path_response_schema() {
6400 let spec = r##"
6401{
6402 "openapi": "3.1.0",
6403 "paths": {
6404 "/widgets": {
6405 "get": {
6406 "operationId": "list_widgets",
6407 "parameters": [],
6408 "responses": {
6409 "200": {
6410 "description": "ok",
6411 "content": {
6412 "application/json": {
6413 "schema": { "type": "array", "items": { "type": "string" } }
6414 }
6415 }
6416 }
6417 }
6418 },
6419 "post": {
6420 "operationId": "create_widget",
6421 "parameters": [],
6422 "responses": {
6423 "201": {
6424 "description": "created",
6425 "content": {
6426 "application/json": {
6427 "schema": { "$ref": "#/paths/~1widgets/get/responses/200/content/application~1json/schema" }
6428 }
6429 }
6430 }
6431 }
6432 }
6433 }
6434 }
6435}
6436"##;
6437 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6438 let result = OpenApiImporter::new(
6439 document,
6440 json_test_source(spec),
6441 LoadOpenApiOptions::default(),
6442 )
6443 .build_ir()
6444 .expect("$ref to path response schema should succeed");
6445 let op = result
6446 .ir
6447 .operations
6448 .iter()
6449 .find(|o| o.name == "create_widget")
6450 .expect("op should exist");
6451 let response = op.responses.first().expect("should have a response");
6452 assert!(
6454 response.type_ref.is_some(),
6455 "response type_ref should be resolved, got: {response:?}"
6456 );
6457 }
6458
6459 #[test]
6460 fn supports_content_based_parameters() {
6461 let spec = r##"
6462{
6463 "openapi": "3.1.0",
6464 "paths": {
6465 "/widgets": {
6466 "get": {
6467 "operationId": "list_widgets",
6468 "parameters": [
6469 {
6470 "name": "filter",
6471 "in": "query",
6472 "content": {
6473 "application/json": {
6474 "schema": { "type": "object", "properties": { "name": { "type": "string" } } }
6475 }
6476 }
6477 }
6478 ],
6479 "responses": { "200": { "description": "ok" } }
6480 }
6481 }
6482 }
6483}
6484"##;
6485 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6486 let result = OpenApiImporter::new(
6487 document,
6488 json_test_source(spec),
6489 LoadOpenApiOptions::default(),
6490 )
6491 .build_ir()
6492 .expect("content-based parameter should succeed");
6493 let op = result
6494 .ir
6495 .operations
6496 .iter()
6497 .find(|o| o.name == "list_widgets")
6498 .expect("op should exist");
6499 assert_eq!(op.params.len(), 1);
6500 assert_eq!(op.params[0].name, "filter");
6501 }
6502
6503 #[test]
6504 fn supports_format_string_as_type_inference() {
6505 let spec = r##"
6506{
6507 "openapi": "3.1.0",
6508 "paths": {},
6509 "components": {
6510 "schemas": {
6511 "Widget": {
6512 "type": "object",
6513 "properties": {
6514 "name": { "format": "string", "description": "The widget name" },
6515 "score": { "format": "float" }
6516 }
6517 }
6518 }
6519 }
6520}
6521"##;
6522 let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6523 let result = OpenApiImporter::new(
6524 document,
6525 json_test_source(spec),
6526 LoadOpenApiOptions::default(),
6527 )
6528 .build_ir()
6529 .expect("format:string schema shape should succeed");
6530 let model = result
6531 .ir
6532 .models
6533 .iter()
6534 .find(|m| m.name == "Widget")
6535 .expect("model should exist");
6536 let name_field = model
6537 .fields
6538 .iter()
6539 .find(|f| f.name == "name")
6540 .expect("name field should exist");
6541 assert!(matches!(&name_field.type_ref, t if format!("{t:?}").contains("string")));
6542 let score_field = model
6543 .fields
6544 .iter()
6545 .find(|f| f.name == "score")
6546 .expect("score field should exist");
6547 assert!(matches!(&score_field.type_ref, t if format!("{t:?}").contains("number")));
6548 }
6549}