1use crate::openapi::{Discriminator, OpenApiSpec, Schema, SchemaType as OpenApiSchemaType};
2use crate::type_mapping::TypeMapper;
3use crate::{GeneratorError, Result};
4use serde_json::Value;
5use std::collections::{BTreeMap, HashSet};
6use std::path::Path;
7
8fn extract_enum_extensions(
15 original: &Value,
16 enum_value_count: usize,
17 schema_name: &str,
18) -> Option<EnumExtensions> {
19 let obj = original.as_object()?;
20
21 let read_string_array = |key: &str| -> Option<Vec<String>> {
22 let arr = obj.get(key)?.as_array()?;
23 let mut out = Vec::with_capacity(arr.len());
24 for v in arr {
25 out.push(v.as_str()?.to_string());
26 }
27 Some(out)
28 };
29
30 let varnames_raw = read_string_array("x-enum-varnames");
31 let descriptions_raw = read_string_array("x-enum-descriptions");
32
33 if varnames_raw.is_none() && descriptions_raw.is_none() {
34 return None;
35 }
36
37 let validate = |label: &str, vals: Option<Vec<String>>| -> Vec<String> {
38 let Some(vals) = vals else {
39 return Vec::new();
40 };
41 if vals.len() == enum_value_count {
42 vals
43 } else {
44 eprintln!(
45 "⚠️ {schema_name}: dropping {label} (expected {enum_value_count} entries, got {})",
46 vals.len()
47 );
48 Vec::new()
49 }
50 };
51
52 let varnames = validate("x-enum-varnames", varnames_raw);
53 let descriptions = validate("x-enum-descriptions", descriptions_raw);
54
55 if varnames.is_empty() && descriptions.is_empty() {
56 return None;
57 }
58 Some(EnumExtensions {
59 varnames,
60 descriptions,
61 })
62}
63
64#[derive(Debug, Clone)]
65pub struct SchemaAnalysis {
66 pub schemas: BTreeMap<String, AnalyzedSchema>,
68 pub dependencies: DependencyGraph,
70 pub patterns: DetectedPatterns,
72 pub operations: BTreeMap<String, OperationInfo>,
74 pub operation_id_aliases: BTreeMap<String, Vec<String>>,
78 pub used_type_features: crate::type_mapping::UsedFeatures,
87 pub enum_extensions: BTreeMap<String, EnumExtensions>,
95 pub validation_context: ValidationContext,
99}
100
101#[derive(Debug, Clone, Default)]
102pub struct ValidationContext {
103 pub openapi_version: String,
104 pub json_schema_dialect: Option<String>,
105 pub component_schemas: BTreeMap<String, Value>,
106}
107
108#[derive(Debug, Clone, Default)]
113pub struct EnumExtensions {
114 pub varnames: Vec<String>,
119 pub descriptions: Vec<String>,
121}
122
123#[derive(Debug, Clone)]
124pub struct AnalyzedSchema {
125 pub name: String,
126 pub original: Value,
127 pub schema_type: SchemaType,
128 pub dependencies: HashSet<String>,
129 pub nullable: bool,
130 pub description: Option<String>,
131 pub default: Option<serde_json::Value>,
132}
133
134#[derive(Debug, Clone)]
135pub enum SchemaType {
136 Primitive {
142 rust_type: String,
143 serde_with: Option<String>,
144 },
145 Object {
147 properties: BTreeMap<String, PropertyInfo>,
148 required: HashSet<String>,
149 additional_properties: ObjectAdditionalProperties,
150 },
151 DiscriminatedUnion {
153 discriminator_field: String,
154 variants: Vec<UnionVariant>,
155 },
156 Union { variants: Vec<SchemaRef> },
158 Array { item_type: Box<SchemaType> },
160 StringEnum { values: Vec<String> },
162 ExtensibleEnum { known_values: Vec<String> },
164 Composition { schemas: Vec<SchemaRef> },
166 Reference { target: String },
168}
169
170#[derive(Debug, Clone)]
175pub enum ObjectAdditionalProperties {
176 Forbidden,
179 Untyped,
182 Typed { value_type: Box<SchemaType> },
185}
186
187impl ObjectAdditionalProperties {
188 pub fn is_open(&self) -> bool {
191 !matches!(self, Self::Forbidden)
192 }
193}
194
195#[derive(Debug, Clone)]
196pub struct PropertyInfo {
197 pub schema_type: SchemaType,
198 pub nullable: bool,
199 pub description: Option<String>,
200 pub default: Option<serde_json::Value>,
201 pub serde_attrs: Vec<String>,
202 pub constraints: PropertyConstraints,
207}
208
209#[derive(Debug, Clone, Default)]
214pub struct PropertyConstraints {
215 pub minimum: Option<f64>,
216 pub maximum: Option<f64>,
217 pub exclusive_minimum: Option<f64>,
218 pub exclusive_maximum: Option<f64>,
219 pub multiple_of: Option<f64>,
220 pub min_length: Option<u64>,
221 pub max_length: Option<u64>,
222 pub pattern: Option<String>,
223 pub min_items: Option<u64>,
224 pub max_items: Option<u64>,
225 pub unique_items: Option<bool>,
226}
227
228impl PropertyConstraints {
229 pub fn is_empty(&self) -> bool {
230 self.minimum.is_none()
231 && self.maximum.is_none()
232 && self.exclusive_minimum.is_none()
233 && self.exclusive_maximum.is_none()
234 && self.multiple_of.is_none()
235 && self.min_length.is_none()
236 && self.max_length.is_none()
237 && self.pattern.is_none()
238 && self.min_items.is_none()
239 && self.max_items.is_none()
240 && self.unique_items.is_none()
241 }
242
243 pub fn from_schema_details(details: &crate::openapi::SchemaDetails) -> Self {
248 use crate::openapi::ExclusiveBound;
249 let exclusive_minimum = match &details.exclusive_minimum {
250 Some(ExclusiveBound::Number(v)) => Some(*v),
251 _ => None,
252 };
253 let exclusive_maximum = match &details.exclusive_maximum {
254 Some(ExclusiveBound::Number(v)) => Some(*v),
255 _ => None,
256 };
257 Self {
258 minimum: details.minimum,
259 maximum: details.maximum,
260 exclusive_minimum,
261 exclusive_maximum,
262 multiple_of: details.multiple_of,
263 min_length: details.min_length,
264 max_length: details.max_length,
265 pattern: details.pattern.clone(),
266 min_items: details.min_items,
267 max_items: details.max_items,
268 unique_items: details.unique_items,
269 }
270 }
271}
272
273#[derive(Debug, Clone)]
274pub struct UnionVariant {
275 pub rust_name: String,
276 pub type_name: String,
277 pub discriminator_value: String,
278 pub schema_ref: String,
279}
280
281#[derive(Debug, Clone)]
282pub struct SchemaRef {
283 pub target: String,
284 pub nullable: bool,
285}
286
287#[derive(Debug, Clone)]
288pub struct DependencyGraph {
289 pub edges: BTreeMap<String, HashSet<String>>,
290 pub recursive_schemas: HashSet<String>,
292}
293
294#[derive(Debug, Clone)]
295pub struct DetectedPatterns {
296 pub tagged_enum_schemas: HashSet<String>,
298 pub untagged_enum_schemas: HashSet<String>,
300 pub type_mappings: BTreeMap<String, BTreeMap<String, String>>,
302}
303
304#[derive(Debug, Clone, Default, serde::Serialize)]
306pub struct OperationInfo {
307 pub operation_id: String,
309 pub method: String,
311 pub path: String,
313 pub summary: Option<String>,
315 pub description: Option<String>,
317 pub request_body: Option<RequestBodyContent>,
319 pub request_body_required: bool,
322 pub response_schemas: BTreeMap<String, String>,
324 pub parameters: Vec<ParameterInfo>,
326 pub supports_streaming: bool,
328 pub stream_parameter: Option<String>,
330 pub tags: Vec<String>,
334}
335
336#[derive(Debug, Clone, serde::Serialize)]
338#[serde(tag = "kind")]
339pub enum RequestBodyContent {
340 Json {
341 schema_name: String,
342 media_type: String,
343 #[serde(skip)]
344 validation_schema: Value,
345 },
346 FormUrlEncoded {
347 schema_name: String,
348 media_type: String,
349 #[serde(skip)]
350 validation_schema: Value,
351 },
352 Multipart,
353 OctetStream,
354 TextPlain,
355 SchemaLess {
359 media_type: String,
360 },
361 Unsupported {
362 media_types: Vec<String>,
363 },
364}
365
366impl RequestBodyContent {
367 pub fn schema_name(&self) -> Option<&str> {
369 match self {
370 Self::Json { schema_name, .. } | Self::FormUrlEncoded { schema_name, .. } => {
371 Some(schema_name)
372 }
373 Self::Multipart
374 | Self::OctetStream
375 | Self::TextPlain
376 | Self::SchemaLess { .. }
377 | Self::Unsupported { .. } => None,
378 }
379 }
380}
381
382fn base_param_ident(name: &str) -> String {
386 use heck::ToSnakeCase;
387 let suffix = if name.ends_with("<=") {
388 "_lte"
389 } else if name.ends_with(">=") {
390 "_gte"
391 } else if name.ends_with('<') {
392 "_lt"
393 } else if name.ends_with('>') {
394 "_gt"
395 } else {
396 ""
397 };
398 let stripped = name.trim_end_matches(['<', '>', '=']);
399 let mut snake = stripped.to_snake_case();
400 if snake.is_empty() {
401 snake.push_str("parameter");
402 } else if snake.starts_with(|character: char| character.is_ascii_digit()) {
403 snake.insert(0, '_');
404 }
405 snake.push_str(suffix);
406 snake
407}
408
409#[derive(Debug, Clone, serde::Serialize)]
411pub struct ParameterInfo {
412 pub name: String,
414 pub location: String,
416 pub required: bool,
418 pub schema_ref: Option<String>,
420 pub rust_type: String,
422 pub description: Option<String>,
424 #[serde(skip_serializing_if = "Option::is_none")]
430 pub enum_values: Option<Vec<String>>,
431 #[serde(skip_serializing_if = "Option::is_none")]
439 pub rust_ident: Option<String>,
440 #[serde(skip_serializing_if = "Option::is_none")]
449 pub query_serialization: Option<QuerySerialization>,
450 #[serde(skip)]
453 pub validation_schema: Option<Value>,
454}
455
456#[derive(Debug, Clone, PartialEq, serde::Serialize)]
459pub enum QuerySerialization {
460 FormExplodedObject,
464 FormObject,
467 DeepObject,
470 FormExplodedArray { item_type: ArrayItemType },
473 FormArray { item_type: ArrayItemType },
476 Unsupported { reason: String },
481}
482
483#[derive(Debug, Clone, PartialEq, serde::Serialize)]
490pub enum ArrayItemType {
491 Scalar(String),
493 EnumRef(String),
495}
496
497impl Default for DependencyGraph {
498 fn default() -> Self {
499 Self::new()
500 }
501}
502
503impl DependencyGraph {
504 pub fn new() -> Self {
505 Self {
506 edges: BTreeMap::new(),
507 recursive_schemas: HashSet::new(),
508 }
509 }
510
511 pub fn add_dependency(&mut self, from: String, to: String) {
512 self.edges.entry(from).or_default().insert(to);
513 }
514
515 pub fn topological_sort(&mut self) -> Result<Vec<String>> {
517 self.detect_recursive_schemas();
519
520 let mut temp_edges = self.edges.clone();
522 for (schema, deps) in &mut temp_edges {
523 deps.remove(schema); }
525
526 let mut visited = HashSet::new();
527 let mut temp_visited = HashSet::new();
528 let mut result = Vec::new();
529
530 let mut all_nodes: Vec<_> = temp_edges.keys().collect();
532 all_nodes.sort();
533 for node in all_nodes {
534 if !visited.contains(node) {
535 self.visit_node_recursive(
536 node,
537 &temp_edges,
538 &mut visited,
539 &mut temp_visited,
540 &mut result,
541 )?;
542 }
543 }
544
545 result.reverse();
546 Ok(result)
547 }
548
549 fn detect_recursive_schemas(&mut self) {
550 for (schema, deps) in &self.edges {
551 if deps.contains(schema) {
552 self.recursive_schemas.insert(schema.clone());
554 } else {
555 if self.has_cycle_from(schema, schema, &mut HashSet::new()) {
557 self.recursive_schemas.insert(schema.clone());
558 }
559 }
560 }
561
562 for (schema, deps) in &self.edges {
564 for dep in deps {
565 if let Some(dep_deps) = self.edges.get(dep) {
566 if dep_deps.contains(schema) {
567 self.recursive_schemas.insert(schema.clone());
569 self.recursive_schemas.insert(dep.clone());
570 }
571 }
572 }
573 }
574 }
575
576 fn has_cycle_from(&self, start: &str, current: &str, visited: &mut HashSet<String>) -> bool {
577 if visited.contains(current) {
578 return false; }
580
581 visited.insert(current.to_string());
582
583 if let Some(deps) = self.edges.get(current) {
584 for dep in deps {
585 if dep == start {
586 return true; }
588 if self.has_cycle_from(start, dep, visited) {
589 return true;
590 }
591 }
592 }
593
594 false
595 }
596
597 #[allow(clippy::only_used_in_recursion)]
598 fn visit_node_recursive(
599 &self,
600 node: &str,
601 temp_edges: &BTreeMap<String, HashSet<String>>,
602 visited: &mut HashSet<String>,
603 temp_visited: &mut HashSet<String>,
604 result: &mut Vec<String>,
605 ) -> Result<()> {
606 if temp_visited.contains(node) {
607 return Ok(());
609 }
610
611 if visited.contains(node) {
612 return Ok(());
613 }
614
615 temp_visited.insert(node.to_string());
616
617 if let Some(dependencies) = temp_edges.get(node) {
618 let mut sorted_deps: Vec<_> = dependencies.iter().collect();
620 sorted_deps.sort();
621 for dep in sorted_deps {
622 self.visit_node_recursive(dep, temp_edges, visited, temp_visited, result)?;
623 }
624 }
625
626 temp_visited.remove(node);
627 visited.insert(node.to_string());
628 result.push(node.to_string());
629
630 Ok(())
631 }
632}
633
634pub fn merge_schema_extensions(
637 main_spec: Value,
638 extension_paths: &[impl AsRef<Path>],
639) -> Result<Value> {
640 let mut result = main_spec;
641
642 for path in extension_paths {
643 let extension = load_extension_file(path.as_ref())?;
644 result = merge_json_objects_with_replacements(result, extension)?;
645 }
646
647 Ok(result)
648}
649
650fn load_extension_file(path: &Path) -> Result<Value> {
652 let content = std::fs::read_to_string(path).map_err(|e| GeneratorError::FileError {
653 message: format!("Failed to read file {}: {}", path.display(), e),
654 })?;
655
656 serde_json::from_str(&content).map_err(GeneratorError::ParseError)
657}
658
659fn merge_json_objects_with_replacements(main: Value, extension: Value) -> Result<Value> {
661 let replacements = extract_replacement_rules(&extension);
663
664 Ok(merge_json_objects_with_rules(
666 main,
667 extension,
668 &replacements,
669 ))
670}
671
672fn extract_replacement_rules(
674 extension: &Value,
675) -> std::collections::HashMap<String, (String, String)> {
676 let mut rules = std::collections::HashMap::new();
677
678 if let Some(x_replacements) = extension.get("x-replacements") {
679 if let Some(x_replacements_obj) = x_replacements.as_object() {
680 for (schema_name, replacement_rule) in x_replacements_obj {
681 if let Some(rule_obj) = replacement_rule.as_object() {
682 if let (Some(replace), Some(with)) = (
683 rule_obj.get("replace").and_then(|v| v.as_str()),
684 rule_obj.get("with").and_then(|v| v.as_str()),
685 ) {
686 rules.insert(schema_name.clone(), (replace.to_string(), with.to_string()));
687 }
689 }
690 }
691 }
692 }
693
694 rules
695}
696
697fn should_replace_variant(
699 schema_name: &str,
700 extension_refs: &[String],
701 replacements: &std::collections::HashMap<String, (String, String)>,
702) -> bool {
703 for (replace_schema, with_schema) in replacements.values() {
705 if schema_name == replace_schema {
706 let replacement_exists = extension_refs.iter().any(|ext_ref| {
708 let ext_schema_name = ext_ref.split('/').next_back().unwrap_or("");
709 ext_schema_name == with_schema
710 });
711
712 if replacement_exists {
713 return true;
714 }
715 }
716 }
717
718 extension_refs.iter().any(|ext_ref| {
720 let ext_schema_name = ext_ref.split('/').next_back().unwrap_or("");
721 schema_name == ext_schema_name
722 })
723}
724
725fn merge_json_objects_with_rules(
730 main: Value,
731 extension: Value,
732 replacements: &std::collections::HashMap<String, (String, String)>,
733) -> Value {
734 match (main, extension) {
735 (Value::Object(mut main_obj), Value::Object(ext_obj)) => {
737 let main_union_keyword = if main_obj.contains_key("oneOf") {
740 Some("oneOf")
741 } else if main_obj.contains_key("anyOf") {
742 Some("anyOf")
743 } else {
744 None
745 };
746 if let (Some(main_variants), Some(ext_variants)) = (
747 extract_schema_variants(&Value::Object(main_obj.clone())),
748 extract_schema_variants(&Value::Object(ext_obj.clone())),
749 ) {
750 let union_key = main_union_keyword.unwrap_or("oneOf");
751 println!(
752 "🔍 Merging union schemas ({union_key}): {} main variants, {} extension variants",
753 main_variants.len(),
754 ext_variants.len()
755 );
756 let mut merged_variants = Vec::new();
759 let extension_refs: Vec<String> = ext_variants
760 .iter()
761 .filter_map(|v| v.get("$ref").and_then(|r| r.as_str()))
762 .map(|s| s.to_string())
763 .collect();
764
765 for main_variant in main_variants {
767 if let Some(main_ref) = main_variant.get("$ref").and_then(|r| r.as_str()) {
768 let schema_name = main_ref.split('/').next_back().unwrap_or("");
770 let should_replace =
771 should_replace_variant(schema_name, &extension_refs, replacements);
772
773 if should_replace {
774 println!("🔄 REPLACING {} (explicit rule)", schema_name);
775 }
776
777 if !should_replace {
778 merged_variants.push(main_variant);
779 }
780 } else {
781 merged_variants.push(main_variant);
783 }
784 }
785
786 for ext_variant in ext_variants {
788 merged_variants.push(ext_variant);
789 }
790
791 main_obj.remove("oneOf");
793 main_obj.remove("anyOf");
794 main_obj.insert(union_key.to_string(), Value::Array(merged_variants));
795
796 for (key, ext_value) in ext_obj {
798 if key != "oneOf" && key != "anyOf" {
799 match main_obj.get(&key) {
800 Some(main_value) => {
801 let merged_value = merge_json_objects_with_rules(
802 main_value.clone(),
803 ext_value,
804 replacements,
805 );
806 main_obj.insert(key, merged_value);
807 }
808 None => {
809 main_obj.insert(key, ext_value);
810 }
811 }
812 }
813 }
814
815 return Value::Object(main_obj);
816 }
817
818 for (key, ext_value) in ext_obj {
820 match main_obj.get(&key) {
821 Some(main_value) => {
822 let merged_value = merge_json_objects_with_rules(
824 main_value.clone(),
825 ext_value,
826 replacements,
827 );
828 main_obj.insert(key, merged_value);
829 }
830 None => {
831 main_obj.insert(key, ext_value);
833 }
834 }
835 }
836 Value::Object(main_obj)
837 }
838
839 (Value::Array(mut main_arr), Value::Array(ext_arr)) => {
841 main_arr.extend(ext_arr);
842 Value::Array(main_arr)
843 }
844
845 (_, extension) => extension,
847 }
848}
849
850fn extract_schema_variants(obj: &Value) -> Option<Vec<Value>> {
852 if let Value::Object(map) = obj {
853 if let Some(Value::Array(variants)) = map.get("oneOf") {
854 return Some(variants.clone());
855 }
856 if let Some(Value::Array(variants)) = map.get("anyOf") {
857 return Some(variants.clone());
858 }
859 }
860 None
861}
862
863pub struct SchemaAnalyzer {
864 schemas: BTreeMap<String, Schema>,
865 resolved_cache: BTreeMap<String, AnalyzedSchema>,
866 openapi_spec: Value,
867 current_schema_name: Option<String>,
868 component_parameters: BTreeMap<String, crate::openapi::Parameter>,
869 type_mapper: TypeMapper,
874}
875
876impl SchemaAnalyzer {
877 pub fn new(openapi_spec: Value) -> Result<Self> {
881 Self::with_type_mapper(openapi_spec, TypeMapper::default())
882 }
883
884 pub fn with_type_mapper(openapi_spec: Value, type_mapper: TypeMapper) -> Result<Self> {
888 let spec: OpenApiSpec =
889 serde_json::from_value(openapi_spec.clone()).map_err(GeneratorError::ParseError)?;
890 let schemas = Self::extract_schemas(&spec)?;
891
892 let component_parameters = spec
893 .components
894 .as_ref()
895 .and_then(|c| c.parameters.as_ref())
896 .cloned()
897 .unwrap_or_default();
898
899 Ok(Self {
900 schemas,
901 resolved_cache: BTreeMap::new(),
902 openapi_spec,
903 current_schema_name: None,
904 component_parameters,
905 type_mapper,
906 })
907 }
908
909 pub fn new_with_extensions(
912 openapi_spec: Value,
913 extension_paths: &[std::path::PathBuf],
914 ) -> Result<Self> {
915 let merged_spec = merge_schema_extensions(openapi_spec, extension_paths)?;
916 Self::new(merged_spec)
917 }
918
919 pub fn new_with_extensions_and_type_mapper(
922 openapi_spec: Value,
923 extension_paths: &[std::path::PathBuf],
924 type_mapper: TypeMapper,
925 ) -> Result<Self> {
926 let merged_spec = merge_schema_extensions(openapi_spec, extension_paths)?;
927 Self::with_type_mapper(merged_spec, type_mapper)
928 }
929
930 pub fn type_mapper(&self) -> &TypeMapper {
934 &self.type_mapper
935 }
936
937 fn generate_context_aware_name(
940 &self,
941 base_context: &str,
942 type_hint: &str,
943 index: usize,
944 schema: Option<&Schema>,
945 ) -> String {
946 if let Some(schema) = schema {
948 if type_hint == "Array"
950 && matches!(schema.schema_type(), Some(OpenApiSchemaType::Array))
951 {
952 if let Some(items_schema) = &schema.details().items {
953 if let Some(item_type) = items_schema.schema_type() {
955 match item_type {
956 OpenApiSchemaType::Object => {
957 return format!("{base_context}ItemArray");
958 }
959 OpenApiSchemaType::String => {
960 return format!("{base_context}StringArray");
961 }
962 _ => {}
963 }
964 }
965 }
966 }
967 }
968
969 match type_hint {
971 "Array" => {
972 format!("{base_context}Array")
974 }
975 "Variant" | "InlineVariant" => {
976 if index == 0 {
978 format!("{base_context}{type_hint}")
979 } else {
980 format!("{}{}{}", base_context, type_hint, index + 1)
981 }
982 }
983 _ => {
984 format!("{base_context}{type_hint}{index}")
986 }
987 }
988 }
989
990 fn to_pascal_case(&self, s: &str) -> String {
992 s.split(['_', '-'])
993 .filter(|part| !part.is_empty())
994 .map(|part| {
995 let mut chars = part.chars();
996 match chars.next() {
997 None => String::new(),
998 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
999 }
1000 })
1001 .collect()
1002 }
1003
1004 fn extract_schemas(spec: &OpenApiSpec) -> Result<BTreeMap<String, Schema>> {
1005 let schemas = spec.components.as_ref().and_then(|c| c.schemas.as_ref());
1010 Ok(schemas
1011 .map(|m| {
1012 m.iter()
1013 .map(|(k, v)| (k.clone(), v.clone()))
1014 .collect::<BTreeMap<_, _>>()
1015 })
1016 .unwrap_or_default())
1017 }
1018
1019 pub fn analyze(&mut self) -> Result<SchemaAnalysis> {
1020 let validation_context = ValidationContext {
1021 openapi_version: self
1022 .openapi_spec
1023 .get("openapi")
1024 .and_then(Value::as_str)
1025 .unwrap_or_default()
1026 .to_string(),
1027 json_schema_dialect: self
1028 .openapi_spec
1029 .get("jsonSchemaDialect")
1030 .and_then(Value::as_str)
1031 .map(str::to_string),
1032 component_schemas: self
1033 .openapi_spec
1034 .pointer("/components/schemas")
1035 .and_then(Value::as_object)
1036 .map(|schemas| {
1037 schemas
1038 .iter()
1039 .map(|(name, schema)| (name.clone(), schema.clone()))
1040 .collect()
1041 })
1042 .unwrap_or_default(),
1043 };
1044 let mut analysis = SchemaAnalysis {
1045 schemas: BTreeMap::new(),
1046 dependencies: DependencyGraph::new(),
1047 patterns: DetectedPatterns {
1048 tagged_enum_schemas: HashSet::new(),
1049 untagged_enum_schemas: HashSet::new(),
1050 type_mappings: BTreeMap::new(),
1051 },
1052 operations: BTreeMap::new(),
1053 operation_id_aliases: BTreeMap::new(),
1054 used_type_features: crate::type_mapping::UsedFeatures::default(),
1055 enum_extensions: BTreeMap::new(),
1056 validation_context,
1057 };
1058
1059 self.detect_patterns(&mut analysis.patterns)?;
1061
1062 let schema_names: Vec<String> = self.schemas.keys().cloned().collect();
1064 for schema_name in schema_names {
1065 let analyzed = self.analyze_schema(&schema_name)?;
1066
1067 for dep in &analyzed.dependencies {
1069 analysis
1070 .dependencies
1071 .add_dependency(schema_name.clone(), dep.clone());
1072 }
1073
1074 analysis.schemas.insert(schema_name, analyzed);
1075 }
1076
1077 for (inline_name, inline_schema) in &self.resolved_cache {
1080 if !analysis.schemas.contains_key(inline_name) {
1081 analysis
1083 .schemas
1084 .insert(inline_name.clone(), inline_schema.clone());
1085
1086 for dep in &inline_schema.dependencies {
1088 analysis
1089 .dependencies
1090 .add_dependency(inline_name.clone(), dep.clone());
1091 }
1092
1093 let mut schemas_to_update = Vec::new();
1098 for (schema_name, schema) in &analysis.schemas {
1099 if schema_name == inline_name {
1101 continue;
1102 }
1103
1104 if schema.dependencies.contains(inline_name) {
1105 schemas_to_update.push(schema_name.clone());
1107 }
1108 }
1109
1110 for schema_name in schemas_to_update {
1112 analysis
1113 .dependencies
1114 .add_dependency(schema_name, inline_name.clone());
1115 }
1116 }
1117 }
1118
1119 self.analyze_operations(&mut analysis)?;
1121
1122 for (inline_name, inline_schema) in &self.resolved_cache {
1125 if !analysis.schemas.contains_key(inline_name) {
1126 analysis
1127 .schemas
1128 .insert(inline_name.clone(), inline_schema.clone());
1129
1130 for dep in &inline_schema.dependencies {
1132 analysis
1133 .dependencies
1134 .add_dependency(inline_name.clone(), dep.clone());
1135 }
1136 }
1137 }
1138
1139 analysis.used_type_features = self.type_mapper.used_features();
1143
1144 for (name, analyzed) in &analysis.schemas {
1149 let enum_value_count = match &analyzed.schema_type {
1150 SchemaType::StringEnum { values } => values.len(),
1151 SchemaType::ExtensibleEnum { known_values } => known_values.len(),
1152 _ => continue,
1153 };
1154 if let Some(ext) = extract_enum_extensions(&analyzed.original, enum_value_count, name) {
1155 analysis.enum_extensions.insert(name.clone(), ext);
1156 }
1157 }
1158
1159 Ok(analysis)
1160 }
1161
1162 fn detect_patterns(&self, patterns: &mut DetectedPatterns) -> Result<()> {
1163 for (schema_name, schema) in &self.schemas {
1164 if self.is_discriminated_union(schema) {
1166 patterns.tagged_enum_schemas.insert(schema_name.clone());
1167
1168 if let Some(mappings) = self.extract_type_mappings(schema)? {
1170 patterns.type_mappings.insert(schema_name.clone(), mappings);
1171 }
1172 }
1173 else if self.is_simple_union(schema) {
1175 patterns.untagged_enum_schemas.insert(schema_name.clone());
1176 }
1177 }
1178
1179 Ok(())
1180 }
1181
1182 fn is_discriminated_union(&self, schema: &Schema) -> bool {
1183 if schema.is_discriminated_union() {
1185 return true;
1186 }
1187
1188 if let Some(variants) = schema.union_variants() {
1190 return variants.len() > 2 && self.detect_discriminator_field(variants).is_some();
1191 }
1192
1193 false
1194 }
1195
1196 fn all_variants_have_const_field(&self, variants: &[Schema], field_name: &str) -> bool {
1197 variants.iter().all(|variant| {
1198 if let Some(ref_str) = variant.reference() {
1199 if let Some(schema_name) = self.extract_schema_name(ref_str) {
1201 if let Some(schema) = self.schemas.get(schema_name) {
1202 return self.has_const_discriminator_field(schema, field_name);
1203 }
1204 }
1205 } else {
1206 return self.has_const_discriminator_field(variant, field_name);
1208 }
1209 false
1210 })
1211 }
1212
1213 fn branch_resolves_to_object(&self, schema: &Schema) -> bool {
1222 if let Some(ref_str) = schema.reference() {
1224 return match self
1225 .extract_schema_name(ref_str)
1226 .and_then(|n| self.schemas.get(n))
1227 {
1228 Some(target) => self.branch_resolves_to_object(target),
1229 None => false,
1230 };
1231 }
1232 if matches!(
1235 schema,
1236 Schema::AllOf { .. } | Schema::AnyOf { .. } | Schema::OneOf { .. }
1237 ) {
1238 return true;
1239 }
1240 if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object)) {
1241 return true;
1242 }
1243 if schema.inferred_type() == Some(OpenApiSchemaType::Object) {
1244 return true;
1245 }
1246 false
1249 }
1250
1251 fn detect_discriminator_field(&self, variants: &[Schema]) -> Option<String> {
1255 if variants.is_empty() {
1256 return None;
1257 }
1258
1259 let first_variant = &variants[0];
1261 let first_schema = if let Some(ref_str) = first_variant.reference() {
1262 let schema_name = self.extract_schema_name(ref_str)?;
1263 self.schemas.get(schema_name)?
1264 } else {
1265 first_variant
1266 };
1267
1268 let properties = first_schema.details().properties.as_ref()?;
1269 let mut candidates: Vec<String> = Vec::new();
1270
1271 for (field_name, field_schema) in properties {
1272 let details = field_schema.details();
1273 let is_const = details.const_value.is_some()
1274 || details.enum_values.as_ref().is_some_and(|v| v.len() == 1)
1275 || details.extra.contains_key("const");
1276 if is_const {
1277 candidates.push(field_name.clone());
1278 }
1279 }
1280
1281 if candidates.is_empty() {
1282 return None;
1283 }
1284
1285 candidates.sort_by(|a, b| {
1287 if a == "type" {
1288 std::cmp::Ordering::Less
1289 } else if b == "type" {
1290 std::cmp::Ordering::Greater
1291 } else {
1292 a.cmp(b)
1293 }
1294 });
1295
1296 for candidate in &candidates {
1298 if self.all_variants_have_const_field(variants, candidate) {
1299 return Some(candidate.clone());
1300 }
1301 }
1302
1303 None
1304 }
1305
1306 fn has_const_discriminator_field(&self, schema: &Schema, field_name: &str) -> bool {
1307 if let Some(properties) = &schema.details().properties {
1308 if let Some(field) = properties.get(field_name) {
1309 if field.details().const_value.is_some() {
1311 return true;
1312 }
1313 if let Some(enum_vals) = &field.details().enum_values {
1315 return enum_vals.len() == 1;
1316 }
1317 return field.details().extra.contains_key("const");
1319 }
1320 }
1321 false
1322 }
1323
1324 fn is_simple_union(&self, schema: &Schema) -> bool {
1325 if let Some(variants) = schema.union_variants() {
1326 if variants.len() > 1 && !schema.is_nullable_pattern() {
1328 let has_refs = variants.iter().any(|v| v.is_reference());
1329 return has_refs;
1330 }
1331 }
1332 false
1333 }
1334
1335 fn extract_type_mappings(&self, schema: &Schema) -> Result<Option<BTreeMap<String, String>>> {
1336 let variants = schema.union_variants().ok_or_else(|| {
1337 GeneratorError::InvalidSchema("No variants found for discriminated union".to_string())
1338 })?;
1339
1340 let discriminator_field = if let Some(discriminator) = schema.discriminator() {
1342 discriminator.property_name.clone()
1343 } else if let Some(detected) = self.detect_discriminator_field(variants) {
1344 detected
1345 } else {
1346 "type".to_string() };
1348
1349 let mut mappings = BTreeMap::new();
1350
1351 for variant in variants {
1352 if let Some(ref_str) = variant.reference() {
1353 if let Some(type_name) = self.extract_schema_name(ref_str) {
1354 if let Some(variant_schema) = self.schemas.get(type_name) {
1355 if let Some(discriminator_value) = self
1356 .extract_discriminator_value_for_field(
1357 variant_schema,
1358 &discriminator_field,
1359 )
1360 {
1361 mappings.insert(type_name.to_string(), discriminator_value);
1362 }
1363 }
1364 }
1365 }
1366 }
1367
1368 if mappings.is_empty() {
1369 Ok(None)
1370 } else {
1371 Ok(Some(mappings))
1372 }
1373 }
1374
1375 #[allow(dead_code)]
1376 fn extract_discriminator_value(&self, schema: &Schema) -> Option<String> {
1377 self.extract_discriminator_value_for_field(schema, "type")
1378 }
1379
1380 fn extract_discriminator_value_for_field(
1381 &self,
1382 schema: &Schema,
1383 field_name: &str,
1384 ) -> Option<String> {
1385 if let Some(properties) = &schema.details().properties {
1386 if let Some(type_field) = properties.get(field_name) {
1387 if let Some(const_value) = &type_field.details().const_value {
1389 if let Some(value) = const_value.as_str() {
1390 return Some(value.to_string());
1391 }
1392 }
1393 if let Some(enum_values) = &type_field.details().enum_values {
1395 if enum_values.len() == 1 {
1396 return enum_values[0].as_str().map(|s| s.to_string());
1397 }
1398 }
1399 if let Some(const_value) = type_field.details().extra.get("const") {
1401 return const_value.as_str().map(|s| s.to_string());
1402 }
1403 if let Some(stainless_const) = type_field.details().extra.get("x-stainless-const") {
1405 if stainless_const.as_bool() == Some(true) {
1406 if let Some(default_value) = &type_field.details().default {
1407 if let Some(value) = default_value.as_str() {
1408 return Some(value.to_string());
1409 }
1410 }
1411 }
1412 }
1413 }
1414 }
1415 None
1416 }
1417
1418 fn get_any_reference<'a>(&self, schema: &'a Schema) -> Option<&'a str> {
1419 schema.reference().or_else(|| schema.recursive_reference())
1420 }
1421
1422 fn extract_schema_name<'a>(&self, ref_str: &'a str) -> Option<&'a str> {
1423 if ref_str == "#" {
1424 return None; }
1426
1427 let parts: Vec<&str> = ref_str.split('/').collect();
1428
1429 if parts.len() >= 4 && parts[0] == "#" && parts[2] == "schemas" {
1431 return Some(parts[3]);
1432 }
1433
1434 if parts.len() >= 3 && parts[0] == "#" && parts[1] == "definitions" {
1437 return Some(parts[2]);
1438 }
1439
1440 let last = parts.last()?;
1446 if last.is_empty()
1447 || last.chars().all(|c| c.is_ascii_digit())
1448 || matches!(
1449 *last,
1450 "schema" | "properties" | "items" | "additionalProperties"
1451 )
1452 {
1453 return None;
1454 }
1455 let first = last.chars().next().unwrap_or(' ');
1456 if !first.is_ascii_alphabetic() || !first.is_ascii_uppercase() {
1457 return None;
1458 }
1459 Some(last)
1460 }
1461
1462 fn analyze_schema(&mut self, schema_name: &str) -> Result<AnalyzedSchema> {
1463 if let Some(cached) = self.resolved_cache.get(schema_name) {
1465 return Ok(cached.clone());
1466 }
1467
1468 self.current_schema_name = Some(schema_name.to_string());
1470
1471 let schema = self
1472 .schemas
1473 .get(schema_name)
1474 .ok_or_else(|| GeneratorError::UnresolvedReference(schema_name.to_string()))?
1475 .clone();
1476
1477 self.resolved_cache.insert(
1479 schema_name.to_string(),
1480 AnalyzedSchema {
1481 name: schema_name.to_string(),
1482 original: serde_json::to_value(&schema).unwrap_or(Value::Null),
1483 schema_type: SchemaType::Reference {
1484 target: "placeholder".to_string(),
1485 },
1486 dependencies: HashSet::new(),
1487 nullable: false,
1488 description: None,
1489 default: None,
1490 },
1491 );
1492
1493 let analyzed = self.analyze_schema_value(&schema, schema_name)?;
1494
1495 self.resolved_cache
1497 .insert(schema_name.to_string(), analyzed.clone());
1498
1499 Ok(analyzed)
1500 }
1501
1502 fn analyze_schema_value(
1503 &mut self,
1504 schema: &Schema,
1505 schema_name: &str,
1506 ) -> Result<AnalyzedSchema> {
1507 let details = schema.details();
1508 let description = details.description.clone();
1509 let nullable = details.is_nullable() || schema.type_array_contains_null();
1511 let mut dependencies = HashSet::new();
1512
1513 let schema_type = match schema {
1514 Schema::Reference { reference, .. } => {
1515 match self.extract_schema_name(reference) {
1520 Some(name) => {
1521 let target = name.to_string();
1522 dependencies.insert(target.clone());
1523 SchemaType::Reference { target }
1524 }
1525 None => {
1526 eprintln!(
1527 "⚠️ unresolvable $ref `{}` — typing as serde_json::Value",
1528 reference
1529 );
1530 SchemaType::Primitive {
1531 rust_type: "serde_json::Value".to_string(),
1532 serde_with: None,
1533 }
1534 }
1535 }
1536 }
1537 Schema::RecursiveRef { recursive_ref, .. }
1538 | Schema::DynamicRef {
1539 dynamic_ref: recursive_ref,
1540 ..
1541 } => {
1542 if recursive_ref == "#" {
1548 dependencies.insert(schema_name.to_string());
1549 SchemaType::Reference {
1550 target: schema_name.to_string(),
1551 }
1552 } else {
1553 let target = self
1554 .extract_schema_name(recursive_ref)
1555 .unwrap_or(schema_name)
1556 .to_string();
1557 dependencies.insert(target.clone());
1558 SchemaType::Reference { target }
1559 }
1560 }
1561 Schema::Typed { .. } | Schema::TypedMulti { .. } => {
1562 let primary = schema
1563 .schema_type()
1564 .cloned()
1565 .unwrap_or(OpenApiSchemaType::Object);
1566 let format = details.format.as_deref();
1567 match primary {
1568 OpenApiSchemaType::String => {
1569 if let Some(values) = details.string_enum_values() {
1570 SchemaType::StringEnum { values }
1571 } else {
1572 SchemaType::Primitive {
1573 rust_type: self.type_mapper.string_format(format).rust_type,
1574 serde_with: None,
1575 }
1576 }
1577 }
1578 OpenApiSchemaType::Integer => SchemaType::Primitive {
1579 rust_type: self.type_mapper.integer_format(format).rust_type,
1580 serde_with: None,
1581 },
1582 OpenApiSchemaType::Number => SchemaType::Primitive {
1583 rust_type: self.type_mapper.number_format(format).rust_type,
1584 serde_with: None,
1585 },
1586 OpenApiSchemaType::Boolean => SchemaType::Primitive {
1587 rust_type: self.type_mapper.boolean().rust_type,
1588 serde_with: None,
1589 },
1590 OpenApiSchemaType::Array => {
1591 self.analyze_array_schema(schema, schema_name, &mut dependencies)?
1593 }
1594 OpenApiSchemaType::Object => {
1595 if self.should_use_dynamic_json(schema) {
1597 SchemaType::Primitive {
1598 rust_type: self.type_mapper.dynamic_json().rust_type,
1599 serde_with: None,
1600 }
1601 } else {
1602 self.analyze_object_schema(schema, &mut dependencies)?
1604 }
1605 }
1606 _ => SchemaType::Primitive {
1607 rust_type: self.type_mapper.dynamic_json().rust_type,
1608 serde_with: None,
1609 },
1610 }
1611 }
1612 Schema::AnyOf {
1613 any_of,
1614 discriminator,
1615 ..
1616 } => {
1617 self.analyze_anyof_union(
1619 any_of,
1620 discriminator.as_ref(),
1621 &mut dependencies,
1622 schema_name,
1623 )?
1624 }
1625 Schema::OneOf {
1626 one_of,
1627 discriminator,
1628 ..
1629 } => {
1630 self.analyze_oneof_union(
1632 one_of,
1633 discriminator.as_ref(),
1634 schema_name,
1635 &mut dependencies,
1636 )?
1637 }
1638 Schema::AllOf { all_of, .. } => {
1639 self.analyze_allof_composition(all_of, &mut dependencies)?
1641 }
1642 Schema::Untyped { .. } => {
1643 if let Some(inferred) = schema.inferred_type() {
1645 match inferred {
1646 OpenApiSchemaType::Object => {
1647 if self.should_use_dynamic_json(schema) {
1648 SchemaType::Primitive {
1649 rust_type: "serde_json::Value".to_string(),
1650 serde_with: None,
1651 }
1652 } else {
1653 self.analyze_object_schema(schema, &mut dependencies)?
1654 }
1655 }
1656 OpenApiSchemaType::String if details.is_string_enum() => {
1657 SchemaType::StringEnum {
1658 values: details.string_enum_values().unwrap_or_default(),
1659 }
1660 }
1661 _ => SchemaType::Primitive {
1662 rust_type: "serde_json::Value".to_string(),
1663 serde_with: None,
1664 },
1665 }
1666 } else {
1667 SchemaType::Primitive {
1668 rust_type: "serde_json::Value".to_string(),
1669 serde_with: None,
1670 }
1671 }
1672 }
1673 };
1674
1675 Ok(AnalyzedSchema {
1676 name: schema_name.to_string(),
1677 original: serde_json::to_value(schema).unwrap_or(Value::Null), schema_type,
1679 dependencies,
1680 nullable,
1681 description,
1682 default: details.default.clone(),
1683 })
1684 }
1685
1686 fn analyze_object_schema(
1687 &mut self,
1688 schema: &Schema,
1689 dependencies: &mut HashSet<String>,
1690 ) -> Result<SchemaType> {
1691 let details = schema.details();
1692 let properties = &details.properties;
1693 let required = details
1694 .required
1695 .as_ref()
1696 .map(|req| req.iter().cloned().collect::<HashSet<String>>())
1697 .unwrap_or_default();
1698
1699 let mut property_info = BTreeMap::new();
1700
1701 if let Some(props) = properties {
1702 for (prop_name, prop_schema) in props {
1703 let prop_type = if let Schema::AnyOf { any_of, .. } = prop_schema {
1705 if self.should_use_dynamic_json(prop_schema) {
1707 SchemaType::Primitive {
1709 rust_type: "serde_json::Value".to_string(),
1710 serde_with: None,
1711 }
1712 } else if prop_schema.is_nullable_pattern()
1713 && let Some(non_null) = prop_schema.non_null_variant()
1714 {
1715 self.analyze_property_schema_with_context(
1723 non_null,
1724 Some(prop_name),
1725 dependencies,
1726 )?
1727 } else {
1728 let context_name = self
1731 .current_schema_name
1732 .clone()
1733 .unwrap_or_else(|| "Unknown".to_string());
1734
1735 let prop_pascal = self.to_pascal_case(prop_name);
1737 let mut union_type_name = format!("{context_name}{prop_pascal}");
1738
1739 if self.schemas.contains_key(&union_type_name)
1742 || self.resolved_cache.contains_key(&union_type_name)
1743 {
1744 let mut suffix = 2;
1745 loop {
1746 let candidate = format!("{union_type_name}Union{suffix}");
1747 if !self.schemas.contains_key(&candidate)
1748 && !self.resolved_cache.contains_key(&candidate)
1749 {
1750 union_type_name = candidate;
1751 break;
1752 }
1753 suffix += 1;
1754 if suffix > 1000 {
1755 break;
1756 }
1757 }
1758 }
1759
1760 let union_schema_type = self.analyze_anyof_union(
1762 any_of,
1763 prop_schema.discriminator(),
1764 dependencies,
1765 &union_type_name,
1766 )?;
1767
1768 self.resolved_cache.insert(
1770 union_type_name.clone(),
1771 AnalyzedSchema {
1772 name: union_type_name.clone(),
1773 original: serde_json::to_value(prop_schema).unwrap_or(Value::Null),
1774 schema_type: union_schema_type,
1775 dependencies: HashSet::new(),
1776 nullable: false,
1777 description: prop_schema.details().description.clone(),
1778 default: None,
1779 },
1780 );
1781
1782 dependencies.insert(union_type_name.clone());
1784 SchemaType::Reference {
1785 target: union_type_name,
1786 }
1787 }
1788 } else if let Schema::OneOf {
1789 one_of,
1790 discriminator,
1791 ..
1792 } = prop_schema
1793 {
1794 if prop_schema.is_nullable_pattern()
1801 && let Some(non_null) = prop_schema.non_null_variant()
1802 {
1803 let unwrapped = self.analyze_property_schema_with_context(
1804 non_null,
1805 Some(prop_name),
1806 dependencies,
1807 )?;
1808 let prop_details = prop_schema.details();
1809 let prop_nullable = true;
1810 let prop_description = prop_details.description.clone();
1811 let prop_default = prop_details.default.clone();
1812 property_info.insert(
1813 prop_name.clone(),
1814 PropertyInfo {
1815 schema_type: unwrapped,
1816 nullable: prop_nullable,
1817 description: prop_description,
1818 default: prop_default,
1819 serde_attrs: Vec::new(),
1820 constraints: PropertyConstraints::from_schema_details(prop_details),
1821 },
1822 );
1823 continue;
1824 }
1825
1826 let context_name = self
1828 .current_schema_name
1829 .clone()
1830 .unwrap_or_else(|| "Unknown".to_string());
1831 let prop_pascal = self.to_pascal_case(prop_name);
1832 let mut union_type_name = format!("{context_name}{prop_pascal}");
1833 if self.schemas.contains_key(&union_type_name)
1835 || self.resolved_cache.contains_key(&union_type_name)
1836 {
1837 let mut suffix = 2;
1838 loop {
1839 let candidate = format!("{union_type_name}Union{suffix}");
1840 if !self.schemas.contains_key(&candidate)
1841 && !self.resolved_cache.contains_key(&candidate)
1842 {
1843 union_type_name = candidate;
1844 break;
1845 }
1846 suffix += 1;
1847 if suffix > 1000 {
1848 break;
1849 }
1850 }
1851 }
1852
1853 let union_schema_type = self.analyze_oneof_union(
1855 one_of,
1856 discriminator.as_ref(),
1857 &union_type_name,
1858 dependencies,
1859 )?;
1860
1861 self.resolved_cache.insert(
1863 union_type_name.clone(),
1864 AnalyzedSchema {
1865 name: union_type_name.clone(),
1866 original: serde_json::to_value(prop_schema).unwrap_or(Value::Null),
1867 schema_type: union_schema_type,
1868 dependencies: HashSet::new(),
1869 nullable: false,
1870 description: prop_schema.details().description.clone(),
1871 default: None,
1872 },
1873 );
1874
1875 dependencies.insert(union_type_name.clone());
1877 SchemaType::Reference {
1878 target: union_type_name,
1879 }
1880 } else {
1881 self.analyze_property_schema_with_context(
1883 prop_schema,
1884 Some(prop_name),
1885 dependencies,
1886 )?
1887 };
1888
1889 let prop_details = prop_schema.details();
1890 let prop_nullable = prop_details.is_nullable() || prop_schema.is_nullable_pattern();
1892 let prop_description = prop_details.description.clone();
1893 let prop_default = prop_details.default.clone();
1894
1895 property_info.insert(
1896 prop_name.clone(),
1897 PropertyInfo {
1898 schema_type: prop_type,
1899 nullable: prop_nullable,
1900 description: prop_description,
1901 default: prop_default,
1902 serde_attrs: Vec::new(),
1903 constraints: PropertyConstraints::from_schema_details(prop_details),
1904 },
1905 );
1906 }
1907 }
1908
1909 let typed_enabled = self
1917 .type_mapper
1918 .config()
1919 .shape
1920 .as_ref()
1921 .and_then(|s| s.additional_properties_typed)
1922 .unwrap_or(true);
1923
1924 let additional_properties = match &details.additional_properties {
1925 Some(crate::openapi::AdditionalProperties::Boolean(true)) => {
1926 ObjectAdditionalProperties::Untyped
1927 }
1928 Some(crate::openapi::AdditionalProperties::Boolean(false)) => {
1929 ObjectAdditionalProperties::Forbidden
1930 }
1931 Some(crate::openapi::AdditionalProperties::Schema(value_schema)) if typed_enabled => {
1932 let analyzed =
1933 self.analyze_property_schema_with_context(value_schema, None, dependencies)?;
1934 ObjectAdditionalProperties::Typed {
1935 value_type: Box::new(analyzed),
1936 }
1937 }
1938 Some(crate::openapi::AdditionalProperties::Schema(_)) => {
1939 ObjectAdditionalProperties::Untyped
1941 }
1942 None => ObjectAdditionalProperties::Forbidden,
1943 };
1944
1945 Ok(SchemaType::Object {
1946 properties: property_info,
1947 required,
1948 additional_properties,
1949 })
1950 }
1951
1952 fn analyze_property_schema_with_context(
1953 &mut self,
1954 schema: &Schema,
1955 property_name: Option<&str>,
1956 dependencies: &mut HashSet<String>,
1957 ) -> Result<SchemaType> {
1958 if let Some(ref_str) = self.get_any_reference(schema) {
1959 let target_opt = if ref_str == "#" {
1960 Some(
1961 self.find_recursive_anchor_schema()
1962 .unwrap_or_else(|| "UnknownRecursive".to_string()),
1963 )
1964 } else {
1965 self.extract_schema_name(ref_str).map(|s| s.to_string())
1966 };
1967 match target_opt {
1968 Some(target) => {
1969 dependencies.insert(target.clone());
1970 return Ok(SchemaType::Reference { target });
1971 }
1972 None => {
1973 eprintln!(
1974 "⚠️ unresolvable $ref `{}` — typing as serde_json::Value",
1975 ref_str
1976 );
1977 return Ok(SchemaType::Primitive {
1978 rust_type: "serde_json::Value".to_string(),
1979 serde_with: None,
1980 });
1981 }
1982 }
1983 }
1984
1985 if let Some(schema_type) = schema.schema_type() {
1986 match schema_type {
1987 OpenApiSchemaType::String => {
1988 if let Some(enum_values) = schema.details().string_enum_values() {
1990 let context_name = self
1993 .current_schema_name
1994 .clone()
1995 .unwrap_or_else(|| "Unknown".to_string());
1996
1997 let primary_name = if let Some(prop_name) = property_name {
1999 let prop_pascal = self.to_pascal_case(prop_name);
2001 format!("{context_name}{prop_pascal}")
2002 } else {
2003 let suffix = if !enum_values.is_empty() {
2006 let first_value = self.to_pascal_case(&enum_values[0]);
2007 format!("{first_value}Enum")
2008 } else {
2009 "StringEnum".to_string()
2010 };
2011 format!("{context_name}{suffix}")
2012 };
2013
2014 return Ok(self.hoist_inline_string_enum(
2015 schema,
2016 enum_values,
2017 primary_name,
2018 dependencies,
2019 ));
2020 } else {
2021 let mapped = self
2027 .type_mapper
2028 .string_format(schema.details().format.as_deref());
2029 return Ok(SchemaType::Primitive {
2030 rust_type: mapped.rust_type,
2031 serde_with: mapped.serde_with,
2032 });
2033 }
2034 }
2035 OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
2036 let details = schema.details();
2037 let rust_type = self.get_number_rust_type(schema_type.clone(), details);
2038 return Ok(SchemaType::Primitive {
2039 rust_type,
2040 serde_with: None,
2041 });
2042 }
2043 OpenApiSchemaType::Boolean => {
2044 return Ok(SchemaType::Primitive {
2045 rust_type: "bool".to_string(),
2046 serde_with: None,
2047 });
2048 }
2049 OpenApiSchemaType::Array => {
2050 let context_name = if let Some(prop_name) = property_name {
2052 let prop_pascal = self.to_pascal_case(prop_name);
2054 format!(
2055 "{}{}",
2056 self.current_schema_name.as_deref().unwrap_or("Unknown"),
2057 prop_pascal
2058 )
2059 } else {
2060 "ArrayItem".to_string()
2062 };
2063 return self.analyze_array_schema(schema, &context_name, dependencies);
2064 }
2065 OpenApiSchemaType::Object => {
2066 if self.should_use_dynamic_json(schema) {
2068 return Ok(SchemaType::Primitive {
2069 rust_type: "serde_json::Value".to_string(),
2070 serde_with: None,
2071 });
2072 }
2073 let object_type_name = if let Some(prop_name) = property_name {
2075 let prop_pascal = self.to_pascal_case(prop_name);
2077 format!(
2078 "{}{}",
2079 self.current_schema_name.as_deref().unwrap_or("Unknown"),
2080 prop_pascal
2081 )
2082 } else {
2083 format!(
2085 "{}Object",
2086 self.current_schema_name.as_deref().unwrap_or("Unknown")
2087 )
2088 };
2089
2090 let object_type = self.analyze_object_schema(schema, dependencies)?;
2092
2093 let inline_schema = AnalyzedSchema {
2095 name: object_type_name.clone(),
2096 original: serde_json::to_value(schema).unwrap_or(Value::Null),
2097 schema_type: object_type,
2098 dependencies: dependencies.clone(),
2099 nullable: false,
2100 description: schema.details().description.clone(),
2101 default: None,
2102 };
2103
2104 self.resolved_cache
2106 .insert(object_type_name.clone(), inline_schema);
2107 dependencies.insert(object_type_name.clone());
2108
2109 return Ok(SchemaType::Reference {
2111 target: object_type_name,
2112 });
2113 }
2114 _ => {
2115 return Ok(SchemaType::Primitive {
2116 rust_type: "serde_json::Value".to_string(),
2117 serde_with: None,
2118 });
2119 }
2120 }
2121 }
2122
2123 if schema.is_nullable_pattern() {
2125 if let Some(non_null) = schema.non_null_variant() {
2126 return self.analyze_property_schema_with_context(
2127 non_null,
2128 property_name,
2129 dependencies,
2130 );
2131 }
2132 }
2133
2134 if self.should_use_dynamic_json(schema) {
2136 return Ok(SchemaType::Primitive {
2137 rust_type: "serde_json::Value".to_string(),
2138 serde_with: None,
2139 });
2140 }
2141
2142 if let Schema::AllOf { all_of, .. } = schema {
2144 return self.analyze_allof_composition(all_of, dependencies);
2145 }
2146
2147 if let Some(variants) = schema.union_variants() {
2149 match variants.len().cmp(&1) {
2150 std::cmp::Ordering::Equal => {
2151 return self.analyze_property_schema_with_context(
2153 &variants[0],
2154 property_name,
2155 dependencies,
2156 );
2157 }
2158 std::cmp::Ordering::Greater => {
2159 let union_name = if let Some(prop_name) = property_name {
2162 let prop_pascal = self.to_pascal_case(prop_name);
2164 format!(
2165 "{}{}",
2166 self.current_schema_name.as_deref().unwrap_or(""),
2167 prop_pascal
2168 )
2169 } else {
2170 "UnionType".to_string()
2171 };
2172
2173 if let Schema::OneOf {
2175 one_of,
2176 discriminator,
2177 ..
2178 } = schema
2179 {
2180 let oneof_result = self.analyze_oneof_union(
2182 one_of,
2183 discriminator.as_ref(),
2184 &union_name,
2185 dependencies,
2186 )?;
2187
2188 if let SchemaType::Union {
2190 variants: _union_variants,
2191 } = &oneof_result
2192 {
2193 self.resolved_cache.insert(
2195 union_name.clone(),
2196 AnalyzedSchema {
2197 name: union_name.clone(),
2198 original: serde_json::to_value(schema).unwrap_or(Value::Null),
2199 schema_type: oneof_result.clone(),
2200 dependencies: dependencies.clone(),
2201 nullable: false,
2202 description: schema.details().description.clone(),
2203 default: None,
2204 },
2205 );
2206
2207 dependencies.insert(union_name.clone());
2209 return Ok(SchemaType::Reference { target: union_name });
2210 }
2211
2212 return Ok(oneof_result);
2213 } else if let Schema::AnyOf {
2214 any_of,
2215 discriminator,
2216 ..
2217 } = schema
2218 {
2219 let union_analysis = self.analyze_anyof_union(
2221 any_of,
2222 discriminator.as_ref(),
2223 dependencies,
2224 &union_name,
2225 )?;
2226 return Ok(union_analysis);
2227 } else {
2228 let mut union_variants = Vec::new();
2231 for variant in variants {
2232 if let Some(ref_str) = variant.reference() {
2233 if let Some(target) = self.extract_schema_name(ref_str) {
2234 dependencies.insert(target.to_string());
2235 union_variants.push(SchemaRef {
2236 target: target.to_string(),
2237 nullable: false,
2238 });
2239 }
2240 }
2241 }
2242 return Ok(SchemaType::Union {
2243 variants: union_variants,
2244 });
2245 }
2246 }
2247 std::cmp::Ordering::Less => {}
2248 }
2249 }
2250
2251 if let Some(inferred_type) = schema.inferred_type() {
2253 match inferred_type {
2254 OpenApiSchemaType::Object => {
2255 if self.should_use_dynamic_json(schema) {
2257 return Ok(SchemaType::Primitive {
2258 rust_type: "serde_json::Value".to_string(),
2259 serde_with: None,
2260 });
2261 }
2262 return self.analyze_object_schema(schema, dependencies);
2263 }
2264 OpenApiSchemaType::Array => {
2265 let context_name = if let Some(prop_name) = property_name {
2266 let prop_pascal = self.to_pascal_case(prop_name);
2268 format!(
2269 "{}{}",
2270 self.current_schema_name.as_deref().unwrap_or("Unknown"),
2271 prop_pascal
2272 )
2273 } else {
2274 "ArrayItem".to_string()
2276 };
2277 return self.analyze_array_schema(schema, &context_name, dependencies);
2278 }
2279 OpenApiSchemaType::String => {
2280 if let Some(enum_values) = schema.details().string_enum_values() {
2281 return Ok(SchemaType::StringEnum {
2282 values: enum_values,
2283 });
2284 } else {
2285 return Ok(SchemaType::Primitive {
2286 rust_type: "String".to_string(),
2287 serde_with: None,
2288 });
2289 }
2290 }
2291 _ => {
2292 let rust_type = self.openapi_type_to_rust_type(inferred_type, schema.details());
2294 return Ok(SchemaType::Primitive {
2295 rust_type,
2296 serde_with: None,
2297 });
2298 }
2299 }
2300 }
2301
2302 Ok(SchemaType::Primitive {
2303 rust_type: "serde_json::Value".to_string(),
2304 serde_with: None,
2305 })
2306 }
2307
2308 fn analyze_allof_composition(
2309 &mut self,
2310 all_of_schemas: &[Schema],
2311 dependencies: &mut HashSet<String>,
2312 ) -> Result<SchemaType> {
2313 if all_of_schemas.len() == 1 {
2316 if let Schema::Reference { reference, .. } = &all_of_schemas[0] {
2317 if let Some(target) = self.extract_schema_name(reference) {
2318 dependencies.insert(target.to_string());
2319 return Ok(SchemaType::Reference {
2320 target: target.to_string(),
2321 });
2322 }
2323 }
2324 }
2325
2326 let mut merged_properties = BTreeMap::new();
2328 let mut merged_required = HashSet::new();
2329 let mut descriptions = Vec::new();
2330
2331 let current_context = self.current_schema_name.clone();
2333
2334 for schema in all_of_schemas {
2335 match schema {
2336 Schema::Reference { reference, .. } => {
2337 if let Some(target) = self.extract_schema_name(reference) {
2339 dependencies.insert(target.to_string());
2340
2341 let analyzed_ref = self.analyze_schema(target)?;
2343
2344 match &analyzed_ref.schema_type {
2346 SchemaType::Object {
2347 properties,
2348 required,
2349 ..
2350 } => {
2351 for (prop_name, prop_info) in properties {
2353 merged_properties.insert(prop_name.clone(), prop_info.clone());
2354 }
2355 for req in required {
2357 merged_required.insert(req.clone());
2358 }
2359 }
2360 _ => {
2361 if let Some(ref_schema) = self.schemas.get(target).cloned() {
2363 self.merge_schema_into_properties(
2364 &ref_schema,
2365 &mut merged_properties,
2366 &mut merged_required,
2367 dependencies,
2368 )?;
2369 }
2370 }
2371 }
2372 }
2373 }
2374 Schema::Typed {
2375 schema_type: OpenApiSchemaType::Object,
2376 ..
2377 }
2378 | Schema::Untyped { .. } => {
2379 let saved_context = self.current_schema_name.clone();
2381 self.current_schema_name = current_context.clone();
2382
2383 self.merge_schema_into_properties(
2385 schema,
2386 &mut merged_properties,
2387 &mut merged_required,
2388 dependencies,
2389 )?;
2390
2391 self.current_schema_name = saved_context;
2393 }
2394 _ => {
2395 self.merge_schema_into_properties(
2398 schema,
2399 &mut merged_properties,
2400 &mut merged_required,
2401 dependencies,
2402 )?;
2403 }
2404 }
2405
2406 if let Some(desc) = &schema.details().description {
2408 descriptions.push(desc.clone());
2409 }
2410 }
2411
2412 if !merged_properties.is_empty() {
2414 Ok(SchemaType::Object {
2415 properties: merged_properties,
2416 required: merged_required,
2417 additional_properties: ObjectAdditionalProperties::Forbidden,
2418 })
2419 } else {
2420 Ok(SchemaType::Composition {
2422 schemas: all_of_schemas
2423 .iter()
2424 .filter_map(|s| {
2425 if let Some(ref_str) = s.reference() {
2426 if let Some(target) = self.extract_schema_name(ref_str) {
2427 dependencies.insert(target.to_string());
2428 Some(SchemaRef {
2429 target: target.to_string(),
2430 nullable: false,
2431 })
2432 } else {
2433 None
2434 }
2435 } else {
2436 None
2437 }
2438 })
2439 .collect(),
2440 })
2441 }
2442 }
2443
2444 fn merge_schema_into_properties(
2445 &mut self,
2446 schema: &Schema,
2447 merged_properties: &mut BTreeMap<String, PropertyInfo>,
2448 merged_required: &mut HashSet<String>,
2449 dependencies: &mut HashSet<String>,
2450 ) -> Result<()> {
2451 let details = schema.details();
2452
2453 if let Some(properties) = &details.properties {
2455 for (prop_name, prop_schema) in properties {
2456 let prop_type = self.analyze_property_schema_with_context(
2457 prop_schema,
2458 Some(prop_name),
2459 dependencies,
2460 )?;
2461 let prop_details = prop_schema.details();
2462
2463 let nullable = prop_details.is_nullable() || prop_schema.is_nullable_pattern();
2469 merged_properties.insert(
2470 prop_name.clone(),
2471 PropertyInfo {
2472 schema_type: prop_type,
2473 nullable,
2474 description: prop_details.description.clone(),
2475 default: prop_details.default.clone(),
2476 serde_attrs: Vec::new(),
2477 constraints: PropertyConstraints::from_schema_details(prop_details),
2478 },
2479 );
2480 }
2481 }
2482
2483 if let Some(required) = &details.required {
2485 for field in required {
2486 merged_required.insert(field.clone());
2487 }
2488 }
2489
2490 Ok(())
2491 }
2492
2493 fn analyze_oneof_union(
2494 &mut self,
2495 one_of_schemas: &[Schema],
2496 discriminator: Option<&crate::openapi::Discriminator>,
2497 parent_name: &str,
2498 dependencies: &mut HashSet<String>,
2499 ) -> Result<SchemaType> {
2500 if one_of_schemas.len() == 2 {
2503 let null_count = one_of_schemas
2504 .iter()
2505 .filter(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
2506 .count();
2507 if null_count == 1 {
2508 if let Some(non_null) = one_of_schemas
2509 .iter()
2510 .find(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
2511 {
2512 return self
2513 .analyze_schema_value(non_null, parent_name)
2514 .map(|a| a.schema_type);
2515 }
2516 }
2517 }
2518
2519 if discriminator.is_none() {
2521 return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies);
2523 }
2524
2525 if one_of_schemas
2531 .iter()
2532 .any(|s| !self.branch_resolves_to_object(s))
2533 {
2534 return self.analyze_untagged_oneof_union(one_of_schemas, parent_name, dependencies);
2535 }
2536
2537 let discriminator_field = discriminator
2539 .ok_or_else(|| {
2540 GeneratorError::InvalidDiscriminator(
2541 "expected discriminator after guard check".to_string(),
2542 )
2543 })?
2544 .property_name
2545 .clone();
2546
2547 let mut variants = Vec::new();
2548 let mut used_variant_names = std::collections::HashSet::new();
2549
2550 for variant_schema in one_of_schemas {
2551 let ref_info = if let Some(ref_str) = variant_schema.reference() {
2553 Some((ref_str, false))
2554 } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
2555 Some((recursive_ref, true))
2556 } else if let Schema::AllOf { all_of, .. } = variant_schema {
2557 if all_of.len() == 1 {
2559 if let Some(ref_str) = all_of[0].reference() {
2560 Some((ref_str, false))
2561 } else {
2562 all_of[0]
2563 .recursive_reference()
2564 .map(|recursive_ref| (recursive_ref, true))
2565 }
2566 } else {
2567 None
2568 }
2569 } else {
2570 None
2571 };
2572
2573 if let Some((ref_str, is_recursive)) = ref_info {
2574 let schema_name = if is_recursive && ref_str == "#" {
2575 self.find_recursive_anchor_schema()
2577 .or_else(|| self.current_schema_name.clone())
2578 .unwrap_or_else(|| "CompoundFilter".to_string())
2579 } else {
2580 self.extract_schema_name(ref_str)
2581 .map(|s| s.to_string())
2582 .unwrap_or_else(|| "UnknownRef".to_string())
2583 };
2584
2585 if !schema_name.is_empty() {
2586 dependencies.insert(schema_name.clone());
2587
2588 let discriminator_value = if let Some(disc) = discriminator {
2593 if let Some(mappings) = &disc.mapping {
2594 mappings
2597 .iter()
2598 .find(|(_, target_ref)| {
2599 target_ref.as_str() == ref_str
2601 || self
2602 .extract_schema_name(target_ref)
2603 .map(|s| s.to_string())
2604 == Some(schema_name.clone())
2605 })
2606 .map(|(key, _)| key.clone())
2607 .unwrap_or_else(|| {
2608 self.fallback_discriminator_value_for_field(
2609 &schema_name,
2610 &discriminator_field,
2611 )
2612 })
2613 } else {
2614 self.fallback_discriminator_value_for_field(
2615 &schema_name,
2616 &discriminator_field,
2617 )
2618 }
2619 } else {
2620 self.fallback_discriminator_value_for_field(
2621 &schema_name,
2622 &discriminator_field,
2623 )
2624 };
2625
2626 let base_name = self.to_rust_variant_name(&schema_name);
2628 let rust_name =
2629 self.ensure_unique_variant_name(base_name, &mut used_variant_names);
2630
2631 let final_discriminator_value = discriminator_value;
2633
2634 variants.push(UnionVariant {
2635 rust_name,
2636 type_name: schema_name,
2637 discriminator_value: final_discriminator_value,
2638 schema_ref: ref_str.to_string(),
2639 });
2640 }
2641 } else {
2642 let variant_index = variants.len();
2644 let inline_type_name =
2645 self.generate_inline_type_name(variant_schema, variant_index);
2646
2647 let discriminator_value = if let Some(disc) = discriminator {
2649 if let Some(mappings) = &disc.mapping {
2650 mappings
2652 .iter()
2653 .find(|(_, target_ref)| {
2654 target_ref.contains(&format!("variant_{variant_index}"))
2655 })
2656 .map(|(key, _)| key.clone())
2657 .unwrap_or_else(|| {
2658 self.extract_inline_discriminator_value(
2659 variant_schema,
2660 &discriminator_field,
2661 variant_index,
2662 )
2663 })
2664 } else {
2665 self.extract_inline_discriminator_value(
2666 variant_schema,
2667 &discriminator_field,
2668 variant_index,
2669 )
2670 }
2671 } else {
2672 self.extract_inline_discriminator_value(
2673 variant_schema,
2674 &discriminator_field,
2675 variant_index,
2676 )
2677 };
2678
2679 let base_name = if discriminator_value.starts_with("variant_") {
2681 format!("Variant{variant_index}")
2682 } else {
2683 let clean_name = self.discriminator_to_variant_name(&discriminator_value);
2685 self.to_rust_variant_name(&clean_name)
2686 };
2687 let rust_name = self.ensure_unique_variant_name(base_name, &mut used_variant_names);
2688
2689 let final_discriminator_value = discriminator_value;
2691
2692 variants.push(UnionVariant {
2693 rust_name,
2694 type_name: inline_type_name.clone(),
2695 discriminator_value: final_discriminator_value,
2696 schema_ref: format!("inline_{variant_index}"),
2697 });
2698
2699 self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?;
2701 }
2702 }
2703
2704 if variants.is_empty() {
2705 let mut union_variants = Vec::new();
2708
2709 for (variant_index, variant_schema) in one_of_schemas.iter().enumerate() {
2710 if let Some(ref_str) = variant_schema.reference() {
2712 if let Some(schema_name) = self.extract_schema_name(ref_str) {
2713 dependencies.insert(schema_name.to_string());
2714 union_variants.push(SchemaRef {
2715 target: schema_name.to_string(),
2716 nullable: false,
2717 });
2718 }
2719 } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
2720 let schema_name = if recursive_ref == "#" {
2721 self.find_recursive_anchor_schema()
2723 .or_else(|| self.current_schema_name.clone())
2724 .unwrap_or_else(|| "CompoundFilter".to_string())
2725 } else {
2726 self.extract_schema_name(recursive_ref)
2727 .map(|s| s.to_string())
2728 .unwrap_or_else(|| "RecursiveType".to_string())
2729 };
2730 dependencies.insert(schema_name.clone());
2731 union_variants.push(SchemaRef {
2732 target: schema_name,
2733 nullable: false,
2734 });
2735 } else {
2736 let inline_name = self.generate_context_aware_name(
2738 parent_name,
2739 "InlineVariant",
2740 variant_index,
2741 Some(variant_schema),
2742 );
2743 let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?;
2744 let variant_type = analyzed.schema_type;
2745
2746 for dep in &analyzed.dependencies {
2748 dependencies.insert(dep.clone());
2749 }
2750
2751 match &variant_type {
2752 SchemaType::Primitive { rust_type, .. } => {
2754 union_variants.push(SchemaRef {
2755 target: rust_type.clone(),
2756 nullable: false,
2757 });
2758 }
2759 SchemaType::Array { item_type } => {
2761 match item_type.as_ref() {
2762 SchemaType::Primitive { rust_type, .. } => {
2763 let type_name = format!("Vec<{rust_type}>");
2764 union_variants.push(SchemaRef {
2765 target: type_name,
2766 nullable: false,
2767 });
2768 }
2769 SchemaType::Reference { target } => {
2770 let type_name = format!("Vec<{target}>");
2771 union_variants.push(SchemaRef {
2772 target: type_name,
2773 nullable: false,
2774 });
2775 }
2776 _ => {
2777 let inline_type_name = self.generate_context_aware_name(
2779 parent_name,
2780 "Variant",
2781 variant_index,
2782 None,
2783 );
2784 self.add_inline_schema(
2785 &inline_type_name,
2786 variant_schema,
2787 dependencies,
2788 )?;
2789 union_variants.push(SchemaRef {
2790 target: inline_type_name,
2791 nullable: false,
2792 });
2793 }
2794 }
2795 }
2796 SchemaType::Reference { target } => {
2798 union_variants.push(SchemaRef {
2799 target: target.clone(),
2800 nullable: false,
2801 });
2802 }
2803 _ => {
2805 let inline_type_name =
2806 format!("{}Variant{}", parent_name, variant_index + 1);
2807 self.add_inline_schema(
2808 &inline_type_name,
2809 variant_schema,
2810 dependencies,
2811 )?;
2812 union_variants.push(SchemaRef {
2813 target: inline_type_name,
2814 nullable: false,
2815 });
2816 }
2817 }
2818 }
2819 }
2820
2821 if !union_variants.is_empty() {
2822 return Ok(SchemaType::Union {
2823 variants: union_variants,
2824 });
2825 }
2826
2827 return Ok(SchemaType::Primitive {
2829 rust_type: "serde_json::Value".to_string(),
2830 serde_with: None,
2831 });
2832 }
2833
2834 Ok(SchemaType::DiscriminatedUnion {
2835 discriminator_field,
2836 variants,
2837 })
2838 }
2839
2840 fn analyze_untagged_oneof_union(
2841 &mut self,
2842 one_of_schemas: &[Schema],
2843 parent_name: &str,
2844 dependencies: &mut HashSet<String>,
2845 ) -> Result<SchemaType> {
2846 let filtered: Vec<&Schema> = one_of_schemas
2850 .iter()
2851 .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
2852 .collect();
2853
2854 if filtered.len() == 1 {
2856 return self
2857 .analyze_schema_value(filtered[0], parent_name)
2858 .map(|a| a.schema_type);
2859 }
2860
2861 let mut union_variants = Vec::new();
2862
2863 for (variant_index, variant_schema) in filtered.iter().copied().enumerate() {
2864 if let Some(ref_str) = variant_schema.reference() {
2866 if let Some(schema_name) = self.extract_schema_name(ref_str) {
2867 dependencies.insert(schema_name.to_string());
2868 union_variants.push(SchemaRef {
2869 target: schema_name.to_string(),
2870 nullable: false,
2871 });
2872 }
2873 } else if let Some(recursive_ref) = variant_schema.recursive_reference() {
2874 let schema_name = if recursive_ref == "#" {
2875 self.find_recursive_anchor_schema()
2877 .or_else(|| self.current_schema_name.clone())
2878 .unwrap_or_else(|| "CompoundFilter".to_string())
2879 } else {
2880 self.extract_schema_name(recursive_ref)
2881 .map(|s| s.to_string())
2882 .unwrap_or_else(|| "RecursiveType".to_string())
2883 };
2884 dependencies.insert(schema_name.clone());
2885 union_variants.push(SchemaRef {
2886 target: schema_name,
2887 nullable: false,
2888 });
2889 } else {
2890 let inline_name = self.generate_context_aware_name(
2892 parent_name,
2893 "InlineVariant",
2894 variant_index,
2895 Some(variant_schema),
2896 );
2897 let analyzed = self.analyze_schema_value(variant_schema, &inline_name)?;
2898 let variant_type = analyzed.schema_type;
2899
2900 for dep in &analyzed.dependencies {
2902 dependencies.insert(dep.clone());
2903 }
2904
2905 match &variant_type {
2906 SchemaType::Primitive { rust_type, .. } => {
2908 union_variants.push(SchemaRef {
2909 target: rust_type.clone(),
2910 nullable: false,
2911 });
2912 }
2913 SchemaType::Array { item_type } => {
2915 match item_type.as_ref() {
2916 SchemaType::Primitive { rust_type, .. } => {
2917 let type_name = format!("Vec<{rust_type}>");
2918 union_variants.push(SchemaRef {
2919 target: type_name,
2920 nullable: false,
2921 });
2922 }
2923 SchemaType::Reference { target } => {
2924 let type_name = format!("Vec<{target}>");
2925 union_variants.push(SchemaRef {
2926 target: type_name,
2927 nullable: false,
2928 });
2929 }
2930 SchemaType::Array {
2932 item_type: inner_item_type,
2933 } => {
2934 match inner_item_type.as_ref() {
2935 SchemaType::Primitive { rust_type, .. } => {
2936 let type_name = format!("Vec<Vec<{rust_type}>>");
2937 union_variants.push(SchemaRef {
2938 target: type_name,
2939 nullable: false,
2940 });
2941 }
2942 SchemaType::Reference { target } => {
2943 let type_name = format!("Vec<Vec<{target}>>");
2944 union_variants.push(SchemaRef {
2945 target: type_name,
2946 nullable: false,
2947 });
2948 }
2949 _ => {
2950 let inline_type_name = self.generate_context_aware_name(
2952 parent_name,
2953 "Variant",
2954 variant_index,
2955 None,
2956 );
2957 self.add_inline_schema(
2958 &inline_type_name,
2959 variant_schema,
2960 dependencies,
2961 )?;
2962 union_variants.push(SchemaRef {
2963 target: inline_type_name,
2964 nullable: false,
2965 });
2966 }
2967 }
2968 }
2969 _ => {
2970 let inline_type_name = self.generate_context_aware_name(
2972 parent_name,
2973 "Variant",
2974 variant_index,
2975 None,
2976 );
2977 self.add_inline_schema(
2978 &inline_type_name,
2979 variant_schema,
2980 dependencies,
2981 )?;
2982 union_variants.push(SchemaRef {
2983 target: inline_type_name,
2984 nullable: false,
2985 });
2986 }
2987 }
2988 }
2989 SchemaType::Reference { target } => {
2991 union_variants.push(SchemaRef {
2992 target: target.clone(),
2993 nullable: false,
2994 });
2995 }
2996 _ => {
2998 let inline_type_name = self.generate_context_aware_name(
2999 parent_name,
3000 "Variant",
3001 variant_index,
3002 None,
3003 );
3004 self.add_inline_schema(&inline_type_name, variant_schema, dependencies)?;
3005 union_variants.push(SchemaRef {
3006 target: inline_type_name,
3007 nullable: false,
3008 });
3009 }
3010 }
3011 }
3012 }
3013
3014 if !union_variants.is_empty() {
3015 return Ok(SchemaType::Union {
3016 variants: union_variants,
3017 });
3018 }
3019
3020 Ok(SchemaType::Primitive {
3022 rust_type: "serde_json::Value".to_string(),
3023 serde_with: None,
3024 })
3025 }
3026
3027 fn add_inline_schema(
3028 &mut self,
3029 type_name: &str,
3030 schema: &Schema,
3031 dependencies: &mut HashSet<String>,
3032 ) -> Result<()> {
3033 if let Some(schema_type) = schema.schema_type() {
3035 match schema_type {
3036 OpenApiSchemaType::String
3037 | OpenApiSchemaType::Integer
3038 | OpenApiSchemaType::Number
3039 | OpenApiSchemaType::Boolean => {
3040 let rust_type =
3041 self.openapi_type_to_rust_type(schema_type.clone(), schema.details());
3042
3043 self.resolved_cache.insert(
3045 type_name.to_string(),
3046 AnalyzedSchema {
3047 name: type_name.to_string(),
3048 original: serde_json::to_value(schema).unwrap_or(Value::Null),
3049 schema_type: SchemaType::Primitive {
3050 rust_type,
3051 serde_with: None,
3052 },
3053 dependencies: HashSet::new(),
3054 nullable: false,
3055 description: schema.details().description.clone(),
3056 default: None,
3057 },
3058 );
3059 return Ok(());
3060 }
3061 _ => {}
3062 }
3063 }
3064
3065 let previous_schema_name = self.current_schema_name.take();
3069 self.current_schema_name = Some(type_name.to_string());
3070 let analyzed = self.analyze_schema_value(schema, type_name)?;
3071 self.current_schema_name = previous_schema_name;
3072
3073 self.resolved_cache.insert(type_name.to_string(), analyzed);
3075
3076 if let Some(cached) = self.resolved_cache.get(type_name) {
3078 for dep in &cached.dependencies {
3079 dependencies.insert(dep.clone());
3080 }
3081 }
3082
3083 Ok(())
3084 }
3085
3086 fn extract_inline_discriminator_value(
3087 &self,
3088 schema: &Schema,
3089 discriminator_field: &str,
3090 variant_index: usize,
3091 ) -> String {
3092 if let Some(properties) = &schema.details().properties {
3094 if let Some(discriminator_prop) = properties.get(discriminator_field) {
3095 if let Some(enum_values) = &discriminator_prop.details().enum_values {
3097 if enum_values.len() == 1 {
3098 if let Some(value) = enum_values[0].as_str() {
3099 return value.to_string();
3100 }
3101 }
3102 }
3103 if let Some(const_value) = discriminator_prop.details().extra.get("const") {
3105 if let Some(value) = const_value.as_str() {
3106 return value.to_string();
3107 }
3108 }
3109 if let Some(const_value) = &discriminator_prop.details().const_value {
3111 if let Some(value) = const_value.as_str() {
3112 return value.to_string();
3113 }
3114 }
3115 }
3116 }
3117
3118 if let Some(inferred_name) = self.infer_variant_name_from_structure(schema, variant_index) {
3120 return inferred_name;
3121 }
3122
3123 format!("variant_{variant_index}")
3125 }
3126
3127 fn infer_variant_name_from_structure(
3128 &self,
3129 schema: &Schema,
3130 _variant_index: usize,
3131 ) -> Option<String> {
3132 let details = schema.details();
3133
3134 if let Some(properties) = &details.properties {
3136 if properties.contains_key("text") && properties.len() <= 3 {
3138 return Some("text".to_string());
3139 }
3140 if properties.contains_key("image") || properties.contains_key("source") {
3141 return Some("image".to_string());
3142 }
3143 if properties.contains_key("document") {
3144 return Some("document".to_string());
3145 }
3146 if properties.contains_key("tool_use_id") || properties.contains_key("tool_result") {
3147 return Some("tool_result".to_string());
3148 }
3149 if properties.contains_key("content") && properties.contains_key("is_error") {
3150 return Some("tool_result".to_string());
3151 }
3152 if properties.contains_key("partial_json") {
3153 return Some("partial_json".to_string());
3154 }
3155
3156 let property_names: Vec<&String> = properties.keys().collect();
3158
3159 for prop_name in &property_names {
3161 if prop_name.contains("result") {
3162 return Some("result".to_string());
3163 }
3164 if prop_name.contains("error") {
3165 return Some("error".to_string());
3166 }
3167 if prop_name.contains("content") && property_names.len() <= 2 {
3168 return Some("content".to_string());
3169 }
3170 }
3171
3172 let significant_props = property_names
3174 .iter()
3175 .filter(|&name| !["type", "id", "cache_control"].contains(&name.as_str()))
3176 .collect::<Vec<_>>();
3177
3178 if significant_props.len() == 1 {
3179 return Some((*significant_props[0]).clone());
3180 }
3181 }
3182
3183 if let Some(description) = &details.description {
3185 let desc_lower = description.to_lowercase();
3186 if desc_lower.contains("text") && desc_lower.len() < 100 {
3187 return Some("text".to_string());
3188 }
3189 if desc_lower.contains("image") {
3190 return Some("image".to_string());
3191 }
3192 if desc_lower.contains("document") {
3193 return Some("document".to_string());
3194 }
3195 if desc_lower.contains("tool") && desc_lower.contains("result") {
3196 return Some("tool_result".to_string());
3197 }
3198 }
3199
3200 None
3201 }
3202
3203 fn discriminator_to_variant_name(&self, discriminator: &str) -> String {
3204 if discriminator.is_empty() {
3206 return "Variant".to_string();
3207 }
3208
3209 let mut result = String::new();
3210 let mut next_upper = true;
3211
3212 for c in discriminator.chars() {
3213 match c {
3214 'a'..='z' => {
3215 if next_upper {
3216 result.push(c.to_ascii_uppercase());
3217 next_upper = false;
3218 } else {
3219 result.push(c);
3220 }
3221 }
3222 'A'..='Z' => {
3223 result.push(c);
3224 next_upper = false;
3225 }
3226 '0'..='9' => {
3227 result.push(c);
3228 next_upper = false;
3229 }
3230 '_' | '-' | '.' | ' ' | '/' | '\\' => {
3231 next_upper = true;
3233 }
3234 _ => {
3235 next_upper = true;
3237 }
3238 }
3239 }
3240
3241 if result.is_empty() || result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
3243 result = format!("Variant{result}");
3244 }
3245
3246 result
3247 }
3248
3249 fn ensure_unique_variant_name(
3250 &self,
3251 base_name: String,
3252 used_names: &mut std::collections::HashSet<String>,
3253 ) -> String {
3254 let mut candidate = base_name.clone();
3255 let mut counter = 1;
3256
3257 while used_names.contains(&candidate) {
3258 counter += 1;
3259 candidate = format!("{base_name}{counter}");
3260 }
3261
3262 used_names.insert(candidate.clone());
3263 candidate
3264 }
3265
3266 fn generate_inline_type_name(&self, schema: &Schema, variant_index: usize) -> String {
3267 if let Some(meaningful_name) = self.infer_type_name_from_structure(schema) {
3269 return meaningful_name;
3270 }
3271
3272 let context = self.current_schema_name.as_deref().unwrap_or("Inline");
3274 self.generate_context_aware_name(context, "Variant", variant_index, Some(schema))
3275 }
3276
3277 fn infer_type_name_from_structure(&self, schema: &Schema) -> Option<String> {
3278 let details = schema.details();
3279
3280 if let Some(description) = &details.description {
3282 if let Some(name_from_desc) = self.extract_type_name_from_description(description) {
3283 return Some(name_from_desc);
3284 }
3285 }
3286
3287 if let Some(properties) = &details.properties {
3289 if let Some(name_from_props) = self.extract_type_name_from_properties(properties) {
3290 return Some(format!("{name_from_props}Block"));
3291 }
3292 }
3293
3294 None
3295 }
3296
3297 fn extract_type_name_from_description(&self, description: &str) -> Option<String> {
3298 if description.len() > 100 || description.contains('\n') {
3300 return None;
3301 }
3302
3303 let words: Vec<&str> = description
3305 .split_whitespace()
3306 .take(2) .filter(|word| {
3308 let w = word.to_lowercase();
3309 word.len() > 2
3310 && ![
3311 "the", "and", "for", "with", "that", "this", "are", "can", "will", "was",
3312 ]
3313 .contains(&w.as_str())
3314 })
3315 .collect();
3316
3317 if words.is_empty() {
3318 return None;
3319 }
3320
3321 let combined = words.join("_");
3323 let pascal_name = self.discriminator_to_variant_name(&combined);
3324
3325 if !pascal_name.ends_with("Content")
3327 && !pascal_name.ends_with("Block")
3328 && !pascal_name.ends_with("Type")
3329 {
3330 Some(format!("{pascal_name}Content"))
3331 } else {
3332 Some(pascal_name)
3333 }
3334 }
3335
3336 fn extract_type_name_from_properties(
3337 &self,
3338 properties: &std::collections::BTreeMap<String, crate::openapi::Schema>,
3339 ) -> Option<String> {
3340 let significant_props: Vec<&String> = properties
3342 .keys()
3343 .filter(|name| !["type", "id", "cache_control"].contains(&name.as_str()))
3344 .collect();
3345
3346 if significant_props.is_empty() {
3347 return None;
3348 }
3349
3350 if significant_props.len() == 1 {
3352 let prop_name = significant_props[0];
3353 return Some(self.discriminator_to_variant_name(prop_name));
3354 }
3355
3356 let mut sorted_props = significant_props.clone();
3359 sorted_props.sort();
3360 if let Some(first_prop) = sorted_props.first() {
3361 return Some(self.discriminator_to_variant_name(first_prop));
3362 }
3363
3364 None
3365 }
3366
3367 fn openapi_type_to_rust_type(
3368 &self,
3369 openapi_type: OpenApiSchemaType,
3370 details: &crate::openapi::SchemaDetails,
3371 ) -> String {
3372 self.type_mapper.map(openapi_type, details).rust_type
3377 }
3378
3379 #[allow(dead_code)]
3380 fn fallback_discriminator_value(&self, schema_name: &str) -> String {
3381 self.fallback_discriminator_value_for_field(schema_name, "type")
3382 }
3383
3384 fn fallback_discriminator_value_for_field(
3385 &self,
3386 schema_name: &str,
3387 field_name: &str,
3388 ) -> String {
3389 if let Some(ref_schema) = self.schemas.get(schema_name) {
3391 if let Some(extracted) =
3392 self.extract_discriminator_value_for_field(ref_schema, field_name)
3393 {
3394 return extracted;
3395 }
3396 }
3397
3398 self.generate_discriminator_value_from_name(schema_name)
3400 }
3401
3402 fn generate_discriminator_value_from_name(&self, schema_name: &str) -> String {
3403 let mut result = String::new();
3405 let mut chars = schema_name.chars().peekable();
3406 let mut first = true;
3407
3408 while let Some(c) = chars.next() {
3409 if c.is_uppercase()
3410 && !first
3411 && chars
3412 .peek()
3413 .map(|&next| next.is_lowercase())
3414 .unwrap_or(false)
3415 {
3416 result.push('.');
3417 }
3418 result.push(c.to_ascii_lowercase());
3419 first = false;
3420 }
3421
3422 if result.ends_with("event") {
3424 result = result[..result.len() - 5].to_string();
3425 }
3426
3427 if schema_name.starts_with("Response") && !result.starts_with("response.") {
3429 result = format!("response.{}", result.trim_start_matches("response"));
3430 }
3431
3432 result
3433 }
3434
3435 fn to_rust_variant_name(&self, schema_name: &str) -> String {
3436 let mut name = schema_name;
3438
3439 if name.starts_with("Response") && name.len() > 8 {
3441 name = &name[8..]; }
3443
3444 if name.ends_with("Event") && name.len() > 5 {
3446 name = &name[..name.len() - 5]; }
3448
3449 name = name.trim_matches('_');
3451
3452 if name.is_empty() {
3454 schema_name.to_string()
3455 } else {
3456 self.discriminator_to_variant_name(name)
3458 }
3459 }
3460
3461 fn hoist_inline_string_enum(
3485 &mut self,
3486 schema: &Schema,
3487 enum_values: Vec<String>,
3488 primary_name: String,
3489 dependencies: &mut HashSet<String>,
3490 ) -> SchemaType {
3491 fn matches_values(existing: &AnalyzedSchema, values: &[String]) -> bool {
3492 matches!(
3493 &existing.schema_type,
3494 SchemaType::StringEnum { values: existing_values }
3495 if existing_values == values
3496 )
3497 }
3498
3499 let mut enum_type_name = primary_name.clone();
3500 let should_insert = match self.resolved_cache.get(&enum_type_name) {
3501 None => true,
3502 Some(existing) if matches_values(existing, &enum_values) => false,
3503 Some(_) => {
3504 let suffix = enum_values
3507 .first()
3508 .map(|v| self.to_pascal_case(v))
3509 .unwrap_or_else(|| "Variant".to_string());
3510 let candidate = format!("{primary_name}{suffix}");
3511
3512 let resolved = match self.resolved_cache.get(&candidate) {
3513 None => Some((candidate.clone(), true)),
3514 Some(existing) if matches_values(existing, &enum_values) => {
3515 Some((candidate.clone(), false))
3516 }
3517 Some(_) => {
3518 let mut found = None;
3521 for n in 2..1000 {
3522 let numbered = format!("{candidate}_{n}");
3523 match self.resolved_cache.get(&numbered) {
3524 None => {
3525 found = Some((numbered, true));
3526 break;
3527 }
3528 Some(existing) if matches_values(existing, &enum_values) => {
3529 found = Some((numbered, false));
3530 break;
3531 }
3532 Some(_) => continue,
3533 }
3534 }
3535 found
3536 }
3537 };
3538
3539 let (resolved_name, insert) = resolved.unwrap_or((candidate, true));
3540 enum_type_name = resolved_name;
3541 insert
3542 }
3543 };
3544
3545 if should_insert {
3548 self.resolved_cache.insert(
3549 enum_type_name.clone(),
3550 AnalyzedSchema {
3551 name: enum_type_name.clone(),
3552 original: serde_json::to_value(schema).unwrap_or(Value::Null),
3553 schema_type: SchemaType::StringEnum {
3554 values: enum_values,
3555 },
3556 dependencies: HashSet::new(),
3557 nullable: false,
3558 description: schema.details().description.clone(),
3559 default: schema.details().default.clone(),
3560 },
3561 );
3562 }
3563
3564 dependencies.insert(enum_type_name.clone());
3566 SchemaType::Reference {
3567 target: enum_type_name,
3568 }
3569 }
3570
3571 fn analyze_array_schema(
3572 &mut self,
3573 schema: &Schema,
3574 parent_schema_name: &str,
3575 dependencies: &mut HashSet<String>,
3576 ) -> Result<SchemaType> {
3577 let details = schema.details();
3578
3579 if let Some(items_schema) = &details.items {
3581 let item_type = match items_schema.as_ref() {
3583 Schema::Reference { reference, .. } => {
3584 let target = self
3586 .extract_schema_name(reference)
3587 .ok_or_else(|| GeneratorError::UnresolvedReference(reference.to_string()))?
3588 .to_string();
3589 dependencies.insert(target.clone());
3590 SchemaType::Reference { target }
3591 }
3592 Schema::RecursiveRef { recursive_ref, .. } => {
3593 if recursive_ref == "#" {
3595 let target = self
3597 .find_recursive_anchor_schema()
3598 .unwrap_or_else(|| parent_schema_name.to_string());
3599 dependencies.insert(target.clone());
3600 SchemaType::Reference { target }
3601 } else {
3602 let target = self
3603 .extract_schema_name(recursive_ref)
3604 .unwrap_or("RecursiveType")
3605 .to_string();
3606 dependencies.insert(target.clone());
3607 SchemaType::Reference { target }
3608 }
3609 }
3610 Schema::Typed { schema_type, .. } => {
3611 match schema_type {
3613 OpenApiSchemaType::String => {
3614 match items_schema
3618 .details()
3619 .string_enum_values()
3620 .filter(|values| !values.is_empty())
3621 {
3622 Some(values) => self.hoist_inline_string_enum(
3623 items_schema,
3624 values,
3625 format!("{parent_schema_name}Item"),
3626 dependencies,
3627 ),
3628 None => SchemaType::Primitive {
3629 rust_type: "String".to_string(),
3630 serde_with: None,
3631 },
3632 }
3633 }
3634 OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
3635 let details = items_schema.details();
3636 let rust_type = self.get_number_rust_type(schema_type.clone(), details);
3637 SchemaType::Primitive {
3638 rust_type,
3639 serde_with: None,
3640 }
3641 }
3642 OpenApiSchemaType::Boolean => SchemaType::Primitive {
3643 rust_type: "bool".to_string(),
3644 serde_with: None,
3645 },
3646 OpenApiSchemaType::Object => {
3647 let object_type_name = format!("{parent_schema_name}Item");
3649
3650 let object_type =
3652 self.analyze_object_schema(items_schema, dependencies)?;
3653
3654 let inline_schema = AnalyzedSchema {
3656 name: object_type_name.clone(),
3657 original: serde_json::to_value(items_schema).unwrap_or(Value::Null),
3658 schema_type: object_type,
3659 dependencies: dependencies.clone(),
3660 nullable: false,
3661 description: items_schema.details().description.clone(),
3662 default: None,
3663 };
3664
3665 self.resolved_cache
3667 .insert(object_type_name.clone(), inline_schema);
3668 dependencies.insert(object_type_name.clone());
3669
3670 SchemaType::Reference {
3672 target: object_type_name,
3673 }
3674 }
3675 OpenApiSchemaType::Array => {
3676 self.analyze_array_schema(
3678 items_schema,
3679 parent_schema_name,
3680 dependencies,
3681 )?
3682 }
3683 _ => SchemaType::Primitive {
3684 rust_type: "serde_json::Value".to_string(),
3685 serde_with: None,
3686 },
3687 }
3688 }
3689 Schema::OneOf { .. } | Schema::AnyOf { .. } => {
3690 let analyzed = self.analyze_schema_value(items_schema, "ArrayItem")?;
3692
3693 match &analyzed.schema_type {
3695 SchemaType::DiscriminatedUnion { .. } | SchemaType::Union { .. } => {
3696 let union_name = format!("{parent_schema_name}ItemUnion");
3699
3700 let mut union_schema = analyzed;
3702 union_schema.name = union_name.clone();
3703
3704 self.resolved_cache.insert(union_name.clone(), union_schema);
3706
3707 dependencies.insert(union_name.clone());
3709
3710 SchemaType::Reference { target: union_name }
3712 }
3713 _ => analyzed.schema_type,
3714 }
3715 }
3716 Schema::Untyped { .. } => {
3717 if let Some(inferred) = items_schema.inferred_type() {
3719 match inferred {
3720 OpenApiSchemaType::Object => {
3721 let object_type_name = format!("{parent_schema_name}Item");
3723
3724 let object_type =
3726 self.analyze_object_schema(items_schema, dependencies)?;
3727
3728 let inline_schema = AnalyzedSchema {
3730 name: object_type_name.clone(),
3731 original: serde_json::to_value(items_schema)
3732 .unwrap_or(Value::Null),
3733 schema_type: object_type,
3734 dependencies: dependencies.clone(),
3735 nullable: false,
3736 description: items_schema.details().description.clone(),
3737 default: None,
3738 };
3739
3740 self.resolved_cache
3742 .insert(object_type_name.clone(), inline_schema);
3743 dependencies.insert(object_type_name.clone());
3744
3745 SchemaType::Reference {
3747 target: object_type_name,
3748 }
3749 }
3750 OpenApiSchemaType::String => {
3751 match items_schema
3754 .details()
3755 .string_enum_values()
3756 .filter(|values| !values.is_empty())
3757 {
3758 Some(values) => self.hoist_inline_string_enum(
3759 items_schema,
3760 values,
3761 format!("{parent_schema_name}Item"),
3762 dependencies,
3763 ),
3764 None => SchemaType::Primitive {
3765 rust_type: "String".to_string(),
3766 serde_with: None,
3767 },
3768 }
3769 }
3770 OpenApiSchemaType::Integer | OpenApiSchemaType::Number => {
3771 let details = items_schema.details();
3772 let rust_type = self.get_number_rust_type(inferred, details);
3773 SchemaType::Primitive {
3774 rust_type,
3775 serde_with: None,
3776 }
3777 }
3778 OpenApiSchemaType::Boolean => SchemaType::Primitive {
3779 rust_type: "bool".to_string(),
3780 serde_with: None,
3781 },
3782 _ => SchemaType::Primitive {
3783 rust_type: "serde_json::Value".to_string(),
3784 serde_with: None,
3785 },
3786 }
3787 } else {
3788 SchemaType::Primitive {
3789 rust_type: "serde_json::Value".to_string(),
3790 serde_with: None,
3791 }
3792 }
3793 }
3794 _ => SchemaType::Primitive {
3795 rust_type: "serde_json::Value".to_string(),
3796 serde_with: None,
3797 },
3798 };
3799
3800 Ok(SchemaType::Array {
3801 item_type: Box::new(item_type),
3802 })
3803 } else {
3804 Ok(SchemaType::Primitive {
3806 rust_type: "Vec<serde_json::Value>".to_string(),
3807 serde_with: None,
3808 })
3809 }
3810 }
3811
3812 fn get_number_rust_type(
3813 &self,
3814 schema_type: OpenApiSchemaType,
3815 details: &crate::openapi::SchemaDetails,
3816 ) -> String {
3817 let format = details.format.as_deref();
3821 match schema_type {
3822 OpenApiSchemaType::Integer => self.type_mapper.integer_format(format).rust_type,
3823 OpenApiSchemaType::Number => self.type_mapper.number_format(format).rust_type,
3824 _ => self.type_mapper.dynamic_json().rust_type,
3825 }
3826 }
3827
3828 fn analyze_anyof_union(
3829 &mut self,
3830 any_of_schemas: &[Schema],
3831 discriminator: Option<&Discriminator>,
3832 dependencies: &mut HashSet<String>,
3833 context_name: &str,
3834 ) -> Result<SchemaType> {
3835 let filtered_owned: Vec<Schema>;
3840 let any_of_schemas: &[Schema] = if any_of_schemas
3841 .iter()
3842 .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
3843 {
3844 filtered_owned = any_of_schemas
3845 .iter()
3846 .filter(|s| !matches!(s.schema_type(), Some(OpenApiSchemaType::Null)))
3847 .cloned()
3848 .collect();
3849 if filtered_owned.is_empty() {
3850 return Ok(SchemaType::Primitive {
3851 rust_type: "serde_json::Value".to_string(),
3852 serde_with: None,
3853 });
3854 }
3855 if filtered_owned.len() == 1 {
3856 return self
3857 .analyze_schema_value(&filtered_owned[0], context_name)
3858 .map(|a| a.schema_type);
3859 }
3860 &filtered_owned
3861 } else {
3862 any_of_schemas
3863 };
3864
3865 let has_refs = any_of_schemas.iter().any(|s| s.is_reference());
3867 let has_objects = any_of_schemas.iter().any(|s| {
3868 matches!(s.schema_type(), Some(OpenApiSchemaType::Object))
3869 || s.inferred_type() == Some(OpenApiSchemaType::Object)
3870 });
3871 let has_arrays = any_of_schemas
3872 .iter()
3873 .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Array)));
3874
3875 let all_string_like = any_of_schemas.iter().all(|s| {
3878 matches!(s.schema_type(), Some(OpenApiSchemaType::String))
3879 || s.details().const_value.is_some()
3880 });
3881
3882 if (has_refs || has_objects || has_arrays || any_of_schemas.len() > 1) && !all_string_like {
3883 if let Some(disc) = discriminator {
3885 return self.analyze_oneof_union(
3887 any_of_schemas,
3888 Some(disc),
3889 context_name,
3890 dependencies,
3891 );
3892 }
3893
3894 if let Some(disc_field) = self.detect_discriminator_field(any_of_schemas) {
3896 return self.analyze_oneof_union(
3897 any_of_schemas,
3898 Some(&Discriminator {
3899 property_name: disc_field,
3900 mapping: None,
3901 default_mapping: None,
3902 extensions: crate::extensions::Extensions::default(),
3903 }),
3904 context_name,
3905 dependencies,
3906 );
3907 }
3908
3909 let mut variants = Vec::new();
3911
3912 for schema in any_of_schemas {
3913 if let Some(ref_str) = schema.reference() {
3914 if let Some(target) = self.extract_schema_name(ref_str) {
3915 dependencies.insert(target.to_string());
3916 variants.push(SchemaRef {
3917 target: target.to_string(),
3918 nullable: false,
3919 });
3920 }
3921 } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Object))
3922 || schema.inferred_type() == Some(OpenApiSchemaType::Object)
3923 {
3924 let inline_index = variants.len();
3926 let inline_type_name = self.generate_inline_type_name(schema, inline_index);
3927
3928 self.add_inline_schema(&inline_type_name, schema, dependencies)?;
3930
3931 variants.push(SchemaRef {
3932 target: inline_type_name,
3933 nullable: false,
3934 });
3935 } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::Array)) {
3936 let array_type =
3938 self.analyze_array_schema(schema, context_name, dependencies)?;
3939
3940 let array_type_name = if let Some(items_schema) = &schema.details().items {
3942 if let Some(ref_str) = items_schema.reference() {
3943 if let Some(item_type_name) = self.extract_schema_name(ref_str) {
3944 dependencies.insert(item_type_name.to_string());
3945 format!("{item_type_name}Array")
3946 } else {
3947 self.generate_context_aware_name(
3948 context_name,
3949 "Array",
3950 variants.len(),
3951 Some(schema),
3952 )
3953 }
3954 } else {
3955 self.generate_context_aware_name(
3956 context_name,
3957 "Array",
3958 variants.len(),
3959 Some(schema),
3960 )
3961 }
3962 } else {
3963 self.generate_context_aware_name(
3964 context_name,
3965 "Array",
3966 variants.len(),
3967 Some(schema),
3968 )
3969 };
3970
3971 self.resolved_cache.insert(
3973 array_type_name.clone(),
3974 AnalyzedSchema {
3975 name: array_type_name.clone(),
3976 original: serde_json::to_value(schema).unwrap_or(Value::Null),
3977 schema_type: array_type,
3978 dependencies: HashSet::new(),
3979 nullable: false,
3980 description: Some("Array variant in union".to_string()),
3981 default: None,
3982 },
3983 );
3984
3985 dependencies.insert(array_type_name.clone());
3987
3988 variants.push(SchemaRef {
3989 target: array_type_name,
3990 nullable: false,
3991 });
3992 } else if let Some(schema_type) = schema.schema_type() {
3993 let primitive_unions = self
4003 .type_mapper
4004 .config_shape_primitive_unions()
4005 .unwrap_or(true);
4006
4007 if primitive_unions {
4008 let mapped = self.type_mapper.map(schema_type.clone(), schema.details());
4009 variants.push(SchemaRef {
4010 target: mapped.rust_type,
4011 nullable: false,
4012 });
4013 } else {
4014 let inline_index = variants.len();
4015 let inline_type_name = match schema_type {
4016 OpenApiSchemaType::String => {
4017 if inline_index == 0 {
4018 format!("{context_name}String")
4019 } else {
4020 format!("{context_name}StringVariant{inline_index}")
4021 }
4022 }
4023 OpenApiSchemaType::Number => {
4024 if inline_index == 0 {
4025 format!("{context_name}Number")
4026 } else {
4027 format!("{context_name}NumberVariant{inline_index}")
4028 }
4029 }
4030 OpenApiSchemaType::Integer => {
4031 if inline_index == 0 {
4032 format!("{context_name}Integer")
4033 } else {
4034 format!("{context_name}IntegerVariant{inline_index}")
4035 }
4036 }
4037 OpenApiSchemaType::Boolean => {
4038 if inline_index == 0 {
4039 format!("{context_name}Boolean")
4040 } else {
4041 format!("{context_name}BooleanVariant{inline_index}")
4042 }
4043 }
4044 _ => format!("{context_name}Variant{inline_index}"),
4045 };
4046
4047 let rust_type =
4048 self.openapi_type_to_rust_type(schema_type.clone(), schema.details());
4049
4050 self.resolved_cache.insert(
4051 inline_type_name.clone(),
4052 AnalyzedSchema {
4053 name: inline_type_name.clone(),
4054 original: serde_json::to_value(schema).unwrap_or(Value::Null),
4055 schema_type: SchemaType::Primitive {
4056 rust_type,
4057 serde_with: None,
4058 },
4059 dependencies: HashSet::new(),
4060 nullable: false,
4061 description: schema.details().description.clone(),
4062 default: None,
4063 },
4064 );
4065
4066 dependencies.insert(inline_type_name.clone());
4067
4068 variants.push(SchemaRef {
4069 target: inline_type_name,
4070 nullable: false,
4071 });
4072 }
4073 }
4074 }
4075
4076 if !variants.is_empty() {
4077 return Ok(SchemaType::Union { variants });
4078 }
4079 }
4080
4081 let all_strings = any_of_schemas.iter().all(|schema| {
4083 matches!(schema.schema_type(), Some(OpenApiSchemaType::String))
4084 || schema.details().const_value.is_some()
4085 });
4086
4087 if all_strings {
4088 let mut enum_values = Vec::new();
4090 let mut has_open_string = false;
4091
4092 for schema in any_of_schemas {
4093 if let Some(const_val) = &schema.details().const_value {
4094 if let Some(const_str) = const_val.as_str() {
4095 enum_values.push(const_str.to_string());
4096 }
4097 } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::String)) {
4098 has_open_string = true;
4099 }
4100 }
4101
4102 if !enum_values.is_empty() {
4103 if has_open_string {
4104 return Ok(SchemaType::ExtensibleEnum {
4107 known_values: enum_values,
4108 });
4109 } else {
4110 return Ok(SchemaType::StringEnum {
4112 values: enum_values,
4113 });
4114 }
4115 }
4116 }
4117
4118 Ok(SchemaType::Primitive {
4120 rust_type: "serde_json::Value".to_string(),
4121 serde_with: None,
4122 })
4123 }
4124
4125 fn find_recursive_anchor_schema(&self) -> Option<String> {
4127 for (schema_name, schema) in &self.schemas {
4129 let details = schema.details();
4130 if details.recursive_anchor == Some(true) {
4131 return Some(schema_name.clone());
4132 }
4133 }
4134
4135 None
4139 }
4140
4141 fn should_use_dynamic_json(&self, schema: &Schema) -> bool {
4144 if let Schema::AnyOf { any_of, .. } = schema {
4146 if any_of.len() == 2 {
4147 let has_null = any_of
4148 .iter()
4149 .any(|s| matches!(s.schema_type(), Some(OpenApiSchemaType::Null)));
4150 let has_empty_object = any_of.iter().any(|s| self.is_dynamic_object_pattern(s));
4151
4152 if has_null && has_empty_object {
4153 return true;
4154 }
4155 }
4156 }
4157
4158 self.is_dynamic_object_pattern(schema)
4160 }
4161
4162 fn is_dynamic_object_pattern(&self, schema: &Schema) -> bool {
4164 let is_object = match schema.schema_type() {
4166 Some(OpenApiSchemaType::Object) => true,
4167 None => schema.inferred_type() == Some(OpenApiSchemaType::Object),
4168 _ => false,
4169 };
4170
4171 if !is_object {
4172 return false;
4173 }
4174
4175 let details = schema.details();
4176
4177 if self.has_explicit_additional_properties(schema) {
4180 return false;
4181 }
4182
4183 let no_properties = details
4185 .properties
4186 .as_ref()
4187 .map(|props| props.is_empty())
4188 .unwrap_or(true);
4189
4190 if no_properties {
4191 let has_structural_constraints = details
4194 .required
4195 .as_ref()
4196 .map(|req| req.iter().any(|r| r != "type"))
4197 .unwrap_or(false)
4198 || details.pattern_properties.is_some()
4199 || details.property_names.is_some()
4200 || details.min_properties.is_some()
4201 || details.max_properties.is_some()
4202 || details.dependent_required.is_some()
4203 || details.dependent_schemas.is_some()
4204 || details.if_schema.is_some()
4205 || details.then_schema.is_some()
4206 || details.else_schema.is_some();
4207
4208 return !has_structural_constraints;
4209 }
4210
4211 false
4212 }
4213
4214 fn has_explicit_additional_properties(&self, schema: &Schema) -> bool {
4216 let details = schema.details();
4217
4218 matches!(
4220 &details.additional_properties,
4221 Some(crate::openapi::AdditionalProperties::Boolean(true))
4222 | Some(crate::openapi::AdditionalProperties::Schema(_))
4223 )
4224 }
4225
4226 fn analyze_operations(&mut self, analysis: &mut SchemaAnalysis) -> Result<()> {
4228 let spec: crate::openapi::OpenApiSpec = serde_json::from_value(self.openapi_spec.clone())
4229 .map_err(GeneratorError::ParseError)?;
4230 let mut canonical_operation_ids = HashSet::new();
4235
4236 if let Some(paths) = &spec.paths {
4237 for (path, path_item) in paths {
4238 let resolved = self.resolve_path_item(path_item, &spec)?;
4240 let pi: &crate::openapi::PathItem = resolved.as_ref().unwrap_or(path_item);
4241 self.ingest_path_item_operations(path, pi, analysis, &mut canonical_operation_ids)?;
4242 }
4243 }
4244 if let Some(webhooks) = &spec.webhooks {
4251 for (name, path_item) in webhooks {
4252 let synthetic_path = format!("__webhook__/{name}");
4253 self.ingest_path_item_operations(
4254 &synthetic_path,
4255 path_item,
4256 analysis,
4257 &mut canonical_operation_ids,
4258 )?;
4259 }
4260 }
4261 Ok(())
4262 }
4263
4264 fn resolve_path_item(
4268 &self,
4269 path_item: &crate::openapi::PathItem,
4270 spec: &crate::openapi::OpenApiSpec,
4271 ) -> Result<Option<crate::openapi::PathItem>> {
4272 let Some(reference) = &path_item.reference else {
4273 return Ok(None);
4274 };
4275 let target_name = reference
4276 .strip_prefix("#/components/pathItems/")
4277 .ok_or_else(|| {
4278 GeneratorError::UnresolvedReference(format!(
4279 "Path Item $ref must point at #/components/pathItems/{{name}}, got {reference}"
4280 ))
4281 })?;
4282 let pi = spec
4283 .components
4284 .as_ref()
4285 .and_then(|c| c.path_items.as_ref())
4286 .and_then(|map| map.get(target_name))
4287 .ok_or_else(|| {
4288 GeneratorError::UnresolvedReference(format!(
4289 "Path Item ref {reference} not found in components/pathItems"
4290 ))
4291 })?;
4292 Ok(Some(pi.clone()))
4293 }
4294
4295 fn ingest_path_item_operations(
4296 &mut self,
4297 path: &str,
4298 path_item: &crate::openapi::PathItem,
4299 analysis: &mut SchemaAnalysis,
4300 canonical_operation_ids: &mut HashSet<String>,
4301 ) -> Result<()> {
4302 for (method, operation) in path_item.operations() {
4303 let raw_operation_id = operation
4305 .operation_id
4306 .clone()
4307 .unwrap_or_else(|| Self::generate_operation_id(method, path));
4308
4309 let operation_id = if canonical_operation_ids
4320 .contains(&Self::canonical_operation_id(&raw_operation_id))
4321 {
4322 let method_lower = method.to_lowercase();
4323 let mut candidate = format!("{}_{}", raw_operation_id, method_lower);
4324 let mut suffix = 2;
4325 while canonical_operation_ids.contains(&Self::canonical_operation_id(&candidate)) {
4326 candidate = format!("{}_{}_{}", raw_operation_id, method_lower, suffix);
4327 suffix += 1;
4328 }
4329 eprintln!(
4330 "⚠️ duplicate operationId `{}` at `{} {}` — disambiguated to `{}`",
4331 raw_operation_id, method, path, candidate
4332 );
4333 candidate
4334 } else {
4335 raw_operation_id.clone()
4336 };
4337
4338 let op_info = self.analyze_single_operation(
4339 &operation_id,
4340 method,
4341 path,
4342 operation,
4343 path_item.parameters.as_ref(),
4344 analysis,
4345 )?;
4346 analysis
4347 .operation_id_aliases
4348 .entry(raw_operation_id)
4349 .or_default()
4350 .push(operation_id.clone());
4351 canonical_operation_ids.insert(Self::canonical_operation_id(&operation_id));
4352 analysis.operations.insert(operation_id, op_info);
4353 }
4354 Ok(())
4355 }
4356
4357 fn canonical_operation_id(operation_id: &str) -> String {
4358 use heck::ToPascalCase;
4359 operation_id.replace('.', "_").to_pascal_case()
4360 }
4361
4362 fn generate_operation_id(method: &str, path: &str) -> String {
4365 let mut operation_id = method.to_lowercase();
4367
4368 let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
4370
4371 for part in path_parts {
4372 if part.is_empty() {
4373 continue;
4374 }
4375
4376 let cleaned_part = if part.starts_with('{') && part.ends_with('}') {
4378 &part[1..part.len() - 1]
4379 } else {
4380 part
4381 };
4382
4383 let pascal_case_part = cleaned_part
4385 .split(&['-', '_'][..])
4386 .map(|s| {
4387 let mut chars = s.chars();
4388 match chars.next() {
4389 None => String::new(),
4390 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
4391 }
4392 })
4393 .collect::<String>();
4394
4395 operation_id.push_str(&pascal_case_part);
4396 }
4397
4398 operation_id
4399 }
4400
4401 fn analyze_single_operation(
4403 &mut self,
4404 operation_id: &str,
4405 method: &str,
4406 path: &str,
4407 operation: &crate::openapi::Operation,
4408 path_item_parameters: Option<&Vec<crate::openapi::Parameter>>,
4409 _analysis: &mut SchemaAnalysis,
4410 ) -> Result<OperationInfo> {
4411 let raw_path_item = self
4412 .openapi_spec
4413 .get("paths")
4414 .and_then(|paths| paths.get(path))
4415 .cloned();
4416 let raw_operation = raw_path_item
4417 .as_ref()
4418 .and_then(|path_item| path_item.get(method.to_ascii_lowercase()))
4419 .cloned();
4420 let mut op_info = OperationInfo {
4421 operation_id: operation_id.to_string(),
4422 method: method.to_uppercase(),
4423 path: path.to_string(),
4424 summary: operation.summary.clone(),
4425 description: operation.description.clone(),
4426 request_body: None,
4427 request_body_required: operation
4429 .request_body
4430 .as_ref()
4431 .and_then(|rb| rb.required)
4432 .unwrap_or(false),
4433 response_schemas: BTreeMap::new(),
4434 parameters: Vec::new(),
4435 supports_streaming: false, stream_parameter: None, tags: operation.tags.clone().unwrap_or_default(),
4438 };
4439
4440 if let Some(request_body) = &operation.request_body {
4442 use crate::openapi::{is_form_urlencoded_media_type, is_json_media_type};
4443 if let Some((content_type, maybe_schema)) = request_body.best_content() {
4444 op_info.request_body = if is_json_media_type(content_type) {
4445 match maybe_schema {
4446 Some(s) => {
4447 let validation_schema = self
4448 .raw_request_body_schema(raw_operation.as_ref(), content_type)
4449 .unwrap_or(
4450 serde_json::to_value(s).map_err(GeneratorError::ParseError)?,
4451 );
4452 Some(
4453 self.resolve_or_inline_schema(s, operation_id, "Request")
4454 .map(|name| RequestBodyContent::Json {
4455 schema_name: name,
4456 media_type: content_type.to_string(),
4457 validation_schema,
4458 })?,
4459 )
4460 }
4461 None => Some(RequestBodyContent::SchemaLess {
4462 media_type: content_type.to_string(),
4463 }),
4464 }
4465 } else if is_form_urlencoded_media_type(content_type) {
4466 match maybe_schema {
4467 Some(s) => {
4468 let validation_schema = self
4469 .raw_request_body_schema(raw_operation.as_ref(), content_type)
4470 .unwrap_or(
4471 serde_json::to_value(s).map_err(GeneratorError::ParseError)?,
4472 );
4473 Some(
4474 self.resolve_or_inline_schema(s, operation_id, "Request")
4475 .map(|name| RequestBodyContent::FormUrlEncoded {
4476 schema_name: name,
4477 media_type: content_type.to_string(),
4478 validation_schema,
4479 })?,
4480 )
4481 }
4482 None => Some(RequestBodyContent::SchemaLess {
4483 media_type: content_type.to_string(),
4484 }),
4485 }
4486 } else {
4487 match content_type {
4488 "multipart/form-data" => Some(RequestBodyContent::Multipart),
4489 "application/octet-stream" => Some(RequestBodyContent::OctetStream),
4490 "text/plain" => Some(RequestBodyContent::TextPlain),
4491 _ => None,
4492 }
4493 };
4494 }
4495 if op_info.request_body.is_none() {
4496 let mut media_types = request_body
4497 .content
4498 .as_ref()
4499 .map(|content| content.keys().cloned().collect::<Vec<_>>())
4500 .unwrap_or_default();
4501 media_types.sort();
4502 if !media_types.is_empty() {
4503 op_info.request_body = Some(RequestBodyContent::Unsupported { media_types });
4504 }
4505 }
4506 }
4507
4508 if let Some(responses) = &operation.responses {
4510 for (status_code, response) in responses {
4511 if let Some(content) = response.content.as_ref() {
4517 if content.keys().any(|ct| ct.starts_with("text/event-stream")) {
4518 op_info.supports_streaming = true;
4519 }
4520 }
4521
4522 if let Some(schema) = response.json_schema() {
4523 if let Some(schema_ref) = schema.reference() {
4524 if let Some(schema_name) = self.extract_schema_name(schema_ref) {
4526 op_info
4527 .response_schemas
4528 .insert(status_code.clone(), schema_name.to_string());
4529 }
4530 } else {
4531 let synthetic_name =
4533 self.generate_inline_response_type_name(operation_id, status_code);
4534
4535 let mut deps = HashSet::new();
4537 self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
4538
4539 op_info
4540 .response_schemas
4541 .insert(status_code.clone(), synthetic_name);
4542 }
4543 }
4544 }
4545 }
4546
4547 if op_info.supports_streaming
4550 && let Some(parameters) = &operation.parameters
4551 {
4552 for param in parameters {
4553 if let Some(name) = param.name.as_deref() {
4554 if name.eq_ignore_ascii_case("stream") {
4555 op_info.stream_parameter = Some(name.to_string());
4556 break;
4557 }
4558 }
4559 }
4560 }
4561
4562 if let Some(parameters) = &operation.parameters {
4564 for (index, param) in parameters.iter().enumerate() {
4565 let resolved = self.resolve_parameter(param).into_owned();
4569 let validation_schema = raw_operation
4570 .as_ref()
4571 .and_then(|operation| operation.get("parameters"))
4572 .and_then(Value::as_array)
4573 .and_then(|parameters| parameters.get(index))
4574 .and_then(|parameter| self.raw_parameter_schema(parameter));
4575 if let Some(param_info) =
4576 self.analyze_parameter(&resolved, operation_id, validation_schema)?
4577 {
4578 op_info.parameters.push(param_info);
4579 }
4580 }
4581 }
4582
4583 if let Some(path_params) = path_item_parameters {
4585 let existing_keys: std::collections::HashSet<(String, String)> = op_info
4586 .parameters
4587 .iter()
4588 .map(|p| (p.name.clone(), p.location.clone()))
4589 .collect();
4590 for (index, param) in path_params.iter().enumerate() {
4591 let resolved = self.resolve_parameter(param).into_owned();
4592 let validation_schema = raw_path_item
4593 .as_ref()
4594 .and_then(|path_item| path_item.get("parameters"))
4595 .and_then(Value::as_array)
4596 .and_then(|parameters| parameters.get(index))
4597 .and_then(|parameter| self.raw_parameter_schema(parameter));
4598 if let Some(param_info) =
4599 self.analyze_parameter(&resolved, operation_id, validation_schema)?
4600 {
4601 if !existing_keys
4602 .contains(&(param_info.name.clone(), param_info.location.clone()))
4603 {
4604 op_info.parameters.push(param_info);
4605 }
4606 }
4607 }
4608 }
4609
4610 let mut declared_path_names: std::collections::HashSet<String> = op_info
4618 .parameters
4619 .iter()
4620 .filter(|p| p.location == "path")
4621 .map(|p| p.name.clone())
4622 .collect();
4623 let bytes = path.as_bytes().iter();
4624 let mut current = String::new();
4625 let mut in_brace = false;
4626 let mut synthesized: Vec<String> = Vec::new();
4627 for b in bytes {
4628 match *b {
4629 b'{' => {
4630 in_brace = true;
4631 current.clear();
4632 }
4633 b'}' if in_brace => {
4634 in_brace = false;
4635 if !current.is_empty() && !declared_path_names.contains(¤t) {
4636 synthesized.push(current.clone());
4637 declared_path_names.insert(current.clone());
4638 }
4639 }
4640 _ if in_brace => current.push(*b as char),
4641 _ => {}
4642 }
4643 }
4644 for name in synthesized {
4645 eprintln!(
4646 "⚠️ path `{}` references `{{{}}}` but the spec doesn't declare it as a parameter — synthesizing as required String",
4647 path, name
4648 );
4649 op_info.parameters.push(ParameterInfo {
4650 name,
4651 location: "path".to_string(),
4652 required: true,
4653 schema_ref: None,
4654 rust_type: "String".to_string(),
4655 description: None,
4656 enum_values: None,
4657 rust_ident: None,
4658 query_serialization: None,
4659 validation_schema: None,
4660 });
4661 }
4662
4663 let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
4671 for p in op_info.parameters.iter_mut() {
4672 let raw = base_param_ident(&p.name);
4673 let mut chosen = raw.clone();
4674 let mut suffix = 2;
4675 while !used.insert(chosen.clone()) {
4676 chosen = format!("{raw}_{suffix}");
4677 suffix += 1;
4678 }
4679 p.rust_ident = Some(chosen);
4680 }
4681
4682 Ok(op_info)
4683 }
4684
4685 fn generate_inline_response_type_name(&self, operation_id: &str, status_code: &str) -> String {
4692 use heck::ToPascalCase;
4693 let base_name = operation_id.replace('.', "_").to_pascal_case();
4694 let suffix = Self::status_code_suffix(status_code);
4695 format!("{}Response{}", base_name, suffix)
4696 }
4697
4698 fn status_code_suffix(status_code: &str) -> String {
4705 match status_code {
4706 "" | "200" => String::new(),
4707 "default" | "Default" => "Default".to_string(),
4708 other if other.chars().all(|c| c.is_ascii_digit()) => other.to_string(),
4709 other => other.to_ascii_lowercase(),
4710 }
4711 }
4712
4713 fn generate_inline_request_type_name(&self, operation_id: &str) -> String {
4715 use heck::ToPascalCase;
4716 let base_name = operation_id.replace('.', "_").to_pascal_case();
4720 format!("{}Request", base_name)
4721 }
4722
4723 fn resolve_or_inline_schema(
4726 &mut self,
4727 schema: &crate::openapi::Schema,
4728 operation_id: &str,
4729 suffix: &str,
4730 ) -> Result<String> {
4731 if let Some(schema_ref) = schema.reference()
4732 && let Some(schema_name) = self.extract_schema_name(schema_ref)
4733 {
4734 return Ok(schema_name.to_string());
4735 }
4736 let synthetic_name = if suffix == "Request" {
4738 self.generate_inline_request_type_name(operation_id)
4739 } else {
4740 self.generate_inline_response_type_name(operation_id, "")
4741 };
4742 let mut deps = HashSet::new();
4743 self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
4744 Ok(synthetic_name)
4745 }
4746
4747 fn resolve_parameter<'a>(
4750 &'a self,
4751 param: &'a crate::openapi::Parameter,
4752 ) -> std::borrow::Cow<'a, crate::openapi::Parameter> {
4753 if let Some(ref_str) = param.reference.as_deref() {
4754 if let Some(param_name) = ref_str.strip_prefix("#/components/parameters/") {
4755 if let Some(resolved) = self.component_parameters.get(param_name) {
4756 return std::borrow::Cow::Borrowed(resolved);
4757 }
4758 }
4759 }
4760 std::borrow::Cow::Borrowed(param)
4761 }
4762
4763 fn referenced_schema_is_string_enum(&self, name: &str) -> bool {
4776 if self.resolve_cached_schema(name).is_some_and(|schema| {
4777 matches!(
4778 schema.schema_type,
4779 SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. }
4780 )
4781 }) {
4782 return true;
4783 }
4784 let Some(schema_value) = self
4785 .openapi_spec
4786 .get("components")
4787 .and_then(|c| c.get("schemas"))
4788 .and_then(|s| s.get(name))
4789 else {
4790 return false;
4791 };
4792 let is_string_type = schema_value
4793 .get("type")
4794 .and_then(|v| v.as_str())
4795 .map(|s| s == "string")
4796 .unwrap_or(false);
4797 let has_enum_or_const =
4798 schema_value.get("enum").is_some() || schema_value.get("const").is_some();
4799 is_string_type && has_enum_or_const
4800 }
4801
4802 fn resolve_raw_local_reference(&self, value: &Value) -> Option<Value> {
4803 let Some(reference) = value.get("$ref").and_then(Value::as_str) else {
4804 return Some(value.clone());
4805 };
4806 let pointer = reference.strip_prefix('#')?;
4807 self.openapi_spec.pointer(pointer).cloned()
4808 }
4809
4810 fn raw_request_body_schema(
4811 &self,
4812 operation: Option<&Value>,
4813 content_type: &str,
4814 ) -> Option<Value> {
4815 let request_body = operation?.get("requestBody")?;
4816 self.resolve_raw_local_reference(request_body)?
4817 .get("content")?
4818 .get(content_type)?
4819 .get("schema")
4820 .cloned()
4821 }
4822
4823 fn raw_parameter_schema(&self, parameter: &Value) -> Option<Value> {
4824 self.resolve_raw_local_reference(parameter)?
4825 .get("schema")
4826 .cloned()
4827 }
4828
4829 fn analyze_parameter(
4830 &mut self,
4831 param: &crate::openapi::Parameter,
4832 operation_id: &str,
4833 raw_validation_schema: Option<Value>,
4834 ) -> Result<Option<ParameterInfo>> {
4835 use heck::ToPascalCase;
4836
4837 let name = param.name.as_deref().unwrap_or("");
4838 let location = param.location.as_deref().unwrap_or("");
4839 let required = param.required.unwrap_or(false);
4840 let validation_schema = match raw_validation_schema {
4841 Some(schema) => Some(schema),
4842 None => param
4843 .schema
4844 .as_ref()
4845 .map(serde_json::to_value)
4846 .transpose()
4847 .map_err(GeneratorError::ParseError)?,
4848 };
4849
4850 let mut rust_type = "String".to_string();
4851 let mut schema_ref = None;
4852 let mut enum_values: Option<Vec<String>> = None;
4853 let mut query_serialization: Option<QuerySerialization> = None;
4854
4855 let is_query = location == "query";
4861 let form_style = matches!(param.style.as_deref(), None | Some("form"));
4862 let form_exploded = form_style && param.explode.unwrap_or(true);
4863 let deep_object =
4864 param.style.as_deref() == Some("deepObject") && param.explode != Some(false);
4865
4866 let object_serialization = if !is_query {
4867 None
4868 } else if deep_object {
4869 Some(QuerySerialization::DeepObject)
4870 } else if form_exploded {
4871 Some(QuerySerialization::FormExplodedObject)
4872 } else if form_style {
4873 Some(QuerySerialization::FormObject)
4874 } else {
4875 None
4876 };
4877
4878 if let Some(schema) = ¶m.schema {
4879 if let Some(ref_str) = schema.reference() {
4880 if let Some(name) = self.extract_schema_name(ref_str) {
4886 if self.referenced_schema_is_string_enum(name) {
4887 schema_ref = Some(name.to_string());
4888 } else if object_serialization.is_some()
4889 && self.referenced_schema_is_object(name)
4890 {
4891 schema_ref = Some(name.to_string());
4892 query_serialization = object_serialization.clone();
4893 } else if is_query
4894 && form_style
4895 && let Some(item_type) = self.referenced_array_param_item_type(name)
4896 {
4897 schema_ref = Some(name.to_string());
4903 query_serialization = Some(if form_exploded {
4904 QuerySerialization::FormExplodedArray { item_type }
4905 } else {
4906 QuerySerialization::FormArray { item_type }
4907 });
4908 }
4909 }
4910 } else if object_serialization.is_some() && Self::schema_is_inline_object(schema) {
4911 let op_pascal = operation_id.replace('.', "_").to_pascal_case();
4916 let param_pascal = name.to_pascal_case();
4917 let synthetic_name = format!("{op_pascal}{param_pascal}");
4918 let mut deps = HashSet::new();
4919 self.add_inline_schema(&synthetic_name, schema, &mut deps)?;
4920 schema_ref = Some(synthetic_name);
4921 query_serialization = object_serialization.clone();
4922 } else if is_query
4923 && form_style
4924 && matches!(
4925 schema.schema_type(),
4926 Some(crate::openapi::SchemaType::Array)
4927 )
4928 && let Some(item_type) = self.array_param_item_type(schema)
4929 {
4930 query_serialization = Some(if form_exploded {
4938 QuerySerialization::FormExplodedArray { item_type }
4939 } else {
4940 QuerySerialization::FormArray { item_type }
4941 });
4942 } else if let Some(schema_type) = schema.schema_type() {
4943 let format = schema.details().format.clone();
4949 rust_type = match schema_type {
4950 crate::openapi::SchemaType::Boolean => "bool".to_string(),
4951 crate::openapi::SchemaType::Integer => {
4952 self.type_mapper.integer_format(format.as_deref()).rust_type
4953 }
4954 crate::openapi::SchemaType::Number => {
4955 self.type_mapper.number_format(format.as_deref()).rust_type
4956 }
4957 crate::openapi::SchemaType::String => "String".to_string(),
4958 _ => "String".to_string(),
4959 };
4960
4961 if matches!(schema_type, crate::openapi::SchemaType::String) {
4962 let details = schema.details();
4963 if details.is_string_enum() {
4964 if let Some(values) = details.string_enum_values() {
4965 if !values.is_empty() {
4966 let op_pascal = operation_id.replace('.', "_").to_pascal_case();
4967 let param_pascal = name.to_pascal_case();
4968 rust_type = format!("{op_pascal}{param_pascal}");
4969 enum_values = Some(values);
4970 }
4971 }
4972 }
4973 }
4974 }
4975
4976 if is_query && query_serialization.is_none() {
4977 let referenced_name = schema
4978 .reference()
4979 .and_then(|reference| self.extract_schema_name(reference));
4980 let is_object = referenced_name
4981 .is_some_and(|name| self.referenced_schema_is_object(name))
4982 || Self::schema_is_inline_object(schema);
4983 let is_array = referenced_name
4984 .is_some_and(|name| self.referenced_schema_is_array(name))
4985 || matches!(
4986 schema.schema_type(),
4987 Some(crate::openapi::SchemaType::Array)
4988 );
4989 let is_composed = referenced_name
4990 .is_some_and(|name| self.referenced_schema_is_composed_query_shape(name));
4991 let reason = if param.style.as_deref() == Some("deepObject")
4992 && param.explode == Some(false)
4993 {
4994 Some("style=deepObject with explode=false is undefined by OpenAPI".to_string())
4995 } else if param.style.as_deref() == Some("deepObject") && !is_object {
4996 Some("style=deepObject is defined only for object query parameters".to_string())
4997 } else if is_object {
4998 Some(format!(
4999 "object query parameters do not support style={}",
5000 param.style.as_deref().unwrap_or("form")
5001 ))
5002 } else if is_array && form_style {
5003 Some(
5004 "form array query parameters require scalar or string-enum items"
5005 .to_string(),
5006 )
5007 } else if is_array {
5008 Some(format!(
5009 "array query parameters do not yet support style={}",
5010 param.style.as_deref().unwrap_or("form")
5011 ))
5012 } else if is_composed {
5013 Some(
5014 "composed or union query schemas cannot be projected to an unambiguous flat wire shape"
5015 .to_string(),
5016 )
5017 } else {
5018 None
5019 };
5020 if let Some(reason) = reason {
5021 query_serialization = Some(QuerySerialization::Unsupported { reason });
5022 }
5023 }
5024 }
5025
5026 Ok(Some(ParameterInfo {
5027 name: name.to_string(),
5028 location: location.to_string(),
5029 required,
5030 schema_ref,
5031 rust_type,
5032 description: param.description.clone(),
5033 enum_values,
5034 rust_ident: None,
5035 query_serialization,
5036 validation_schema,
5037 }))
5038 }
5039
5040 fn array_param_item_type(&self, schema: &crate::openapi::Schema) -> Option<ArrayItemType> {
5049 let items = schema.details().items.as_deref()?;
5050 if let Some(ref_str) = items.reference() {
5051 let name = self.extract_schema_name(ref_str)?;
5052 return self
5053 .referenced_schema_is_string_enum(name)
5054 .then(|| ArrayItemType::EnumRef(name.to_string()));
5055 }
5056 let format = items.details().format.clone();
5057 let scalar = match items.schema_type()? {
5058 crate::openapi::SchemaType::String => "String".to_string(),
5059 crate::openapi::SchemaType::Integer => {
5060 self.type_mapper.integer_format(format.as_deref()).rust_type
5061 }
5062 crate::openapi::SchemaType::Number => {
5063 self.type_mapper.number_format(format.as_deref()).rust_type
5064 }
5065 crate::openapi::SchemaType::Boolean => "bool".to_string(),
5066 _ => return None,
5067 };
5068 Some(ArrayItemType::Scalar(scalar))
5069 }
5070
5071 fn referenced_array_param_item_type(&self, name: &str) -> Option<ArrayItemType> {
5074 let schema = self.resolve_cached_schema(name)?;
5075 let SchemaType::Array { item_type } = &schema.schema_type else {
5076 return None;
5077 };
5078 self.analyzed_array_item_type(item_type)
5079 }
5080
5081 fn analyzed_array_item_type(&self, item_type: &SchemaType) -> Option<ArrayItemType> {
5082 match item_type {
5083 SchemaType::Primitive { rust_type, .. } => {
5084 Some(ArrayItemType::Scalar(rust_type.clone()))
5085 }
5086 SchemaType::Reference { target } => {
5087 let resolved = self.resolve_cached_schema(target)?;
5088 matches!(
5089 resolved.schema_type,
5090 SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. }
5091 )
5092 .then(|| ArrayItemType::EnumRef(target.clone()))
5093 }
5094 _ => None,
5095 }
5096 }
5097
5098 fn referenced_schema_is_object(&self, name: &str) -> bool {
5102 self.resolve_cached_schema(name)
5103 .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Object { .. }))
5104 }
5105
5106 fn referenced_schema_is_array(&self, name: &str) -> bool {
5107 self.resolve_cached_schema(name)
5108 .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Array { .. }))
5109 }
5110
5111 fn referenced_schema_is_composed_query_shape(&self, name: &str) -> bool {
5112 self.resolve_cached_schema(name).is_some_and(|schema| {
5113 matches!(
5114 schema.schema_type,
5115 SchemaType::Composition { .. }
5116 | SchemaType::Union { .. }
5117 | SchemaType::DiscriminatedUnion { .. }
5118 )
5119 })
5120 }
5121
5122 fn resolve_cached_schema(&self, name: &str) -> Option<&AnalyzedSchema> {
5123 let mut current = name;
5124 let mut visited = HashSet::new();
5125 loop {
5126 if !visited.insert(current) {
5127 return None;
5128 }
5129 let schema = self.resolved_cache.get(current)?;
5130 if let SchemaType::Reference { target } = &schema.schema_type {
5131 current = target;
5132 } else {
5133 return Some(schema);
5134 }
5135 }
5136 }
5137
5138 fn schema_is_inline_object(schema: &crate::openapi::Schema) -> bool {
5140 match schema.schema_type() {
5141 Some(crate::openapi::SchemaType::Object) => true,
5142 None => schema.details().properties.is_some(),
5143 _ => false,
5144 }
5145 }
5146}