Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 209 additions & 48 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ pub struct DependencyGraph {
pub struct DetectedPatterns {
/// Schemas that should use tagged enums (discriminated unions)
pub tagged_enum_schemas: HashSet<String>,
/// Schemas that should use untagged enums (simple unions)
/// Schemas that should use untagged enums (simple unions)
pub untagged_enum_schemas: HashSet<String>,
/// Auto-detected type mappings for discriminated unions
pub type_mappings: BTreeMap<String, BTreeMap<String, String>>,
Expand Down Expand Up @@ -1758,54 +1758,24 @@ impl SchemaAnalyzer {
}
}
Schema::Typed { .. } | Schema::TypedMulti { .. } => {
let primary = schema
.schema_type()
.cloned()
.unwrap_or(OpenApiSchemaType::Object);
let format = details.format.as_deref();
match primary {
OpenApiSchemaType::String => {
if let Some(values) = details.string_enum_values() {
SchemaType::StringEnum { values }
} else {
SchemaType::Primitive {
rust_type: self.type_mapper.string_format(format).rust_type,
serde_with: None,
}
}
}
OpenApiSchemaType::Integer => SchemaType::Primitive {
rust_type: self.type_mapper.integer_format(format).rust_type,
serde_with: None,
},
OpenApiSchemaType::Number => SchemaType::Primitive {
rust_type: self.type_mapper.number_format(format).rust_type,
serde_with: None,
},
OpenApiSchemaType::Boolean => SchemaType::Primitive {
rust_type: self.type_mapper.boolean().rust_type,
serde_with: None,
},
OpenApiSchemaType::Array => {
// Analyze array item type
self.analyze_array_schema(schema, schema_name, &mut dependencies)?
}
OpenApiSchemaType::Object => {
// Check if this is a dynamic JSON object
if self.should_use_dynamic_json(schema) {
SchemaType::Primitive {
rust_type: self.type_mapper.dynamic_json().rust_type,
serde_with: None,
}
} else {
// Analyze object properties
self.analyze_object_schema(schema, &mut dependencies)?
}
if let Some(non_null_types) = schema.non_null_schema_types() {
let mut variants = Vec::with_capacity(non_null_types.len());
for t in non_null_types {
variants.push(self.build_typed_multi_union_variant(
t,
schema,
schema_name,
&mut dependencies,
)?);
}
_ => SchemaType::Primitive {
rust_type: self.type_mapper.dynamic_json().rust_type,
serde_with: None,
},
SchemaType::Union { variants }
} else {
self.analyze_single_typed_schema(
schema,
schema_name,
details,
&mut dependencies,
)?
}
}
Schema::AnyOf {
Expand Down Expand Up @@ -1882,6 +1852,66 @@ impl SchemaAnalyzer {
})
}

/// Resolve a `Schema::Typed`/`Schema::TypedMulti` schema that carries a
/// single effective type (the 3.1 nullable shorthand already collapses
/// to this via `schema_type()`). Proper multi-type unions are handled in
/// [Self::analyze_schema_value] via
/// [Self::build_typed_multi_union_variant].
fn analyze_single_typed_schema(
&mut self,
schema: &Schema,
schema_name: &str,
details: &crate::openapi::SchemaDetails,
dependencies: &mut HashSet<String>,
) -> Result<SchemaType> {
let primary = schema
.schema_type()
.cloned()
.unwrap_or(OpenApiSchemaType::Object);
let format = details.format.as_deref();
Ok(match primary {
OpenApiSchemaType::String => {
if let Some(values) = details.string_enum_values() {
SchemaType::StringEnum { values }
} else {
SchemaType::Primitive {
rust_type: self.type_mapper.string_format(format).rust_type,
serde_with: None,
}
}
}
OpenApiSchemaType::Integer => SchemaType::Primitive {
rust_type: self.type_mapper.integer_format(format).rust_type,
serde_with: None,
},
OpenApiSchemaType::Number => SchemaType::Primitive {
rust_type: self.type_mapper.number_format(format).rust_type,
serde_with: None,
},
OpenApiSchemaType::Boolean => SchemaType::Primitive {
rust_type: self.type_mapper.boolean().rust_type,
serde_with: None,
},
OpenApiSchemaType::Array => {
self.analyze_array_schema(schema, schema_name, dependencies)?
}
OpenApiSchemaType::Object => {
if self.should_use_dynamic_json(schema) {
SchemaType::Primitive {
rust_type: self.type_mapper.dynamic_json().rust_type,
serde_with: None,
}
} else {
self.analyze_object_schema(schema, dependencies)?
}
}
_ => SchemaType::Primitive {
rust_type: self.type_mapper.dynamic_json().rust_type,
serde_with: None,
},
})
}

fn analyze_object_schema(
&mut self,
schema: &Schema,
Expand Down Expand Up @@ -2148,6 +2178,76 @@ impl SchemaAnalyzer {
})
}

/// Build one union variant for a genuine `type: [X, Y, ...]` member.
/// All members of a `TypedMulti` share a single `SchemaDetails`, so
/// `array`/`object` members carry the *same* `items`/`properties` as
/// the union schema itself — routing them through `TypeMapper::map`
/// (as the scalar members are) would discard that shape and collapse
/// to generic `Vec<serde_json::Value>` / `serde_json::Value`.
///
/// This just properly handles array and object types before passing on to
/// the type mapper.
fn build_typed_multi_union_variant(
&mut self,
member_type: OpenApiSchemaType,
schema: &Schema,
union_type_name: &str,
dependencies: &mut HashSet<String>,
) -> Result<SchemaRef> {
match member_type {
OpenApiSchemaType::Array => {
let array_type_name = format!("{union_type_name}Array");
let array_type =
self.analyze_array_schema(schema, &array_type_name, dependencies)?;
self.resolved_cache.insert(
array_type_name.clone(),
AnalyzedSchema {
name: array_type_name.clone(),
original: serde_json::to_value(schema).unwrap_or(Value::Null),
schema_type: array_type,
dependencies: HashSet::new(),
nullable: false,
description: Some("Array variant in union".to_string()),
default: None,
},
);
dependencies.insert(array_type_name.clone());
Ok(SchemaRef {
target: array_type_name,
nullable: false,
})
}
OpenApiSchemaType::Object => {
let object_type_name = format!("{union_type_name}Object");
let object_type = self.analyze_object_schema(schema, dependencies)?;
self.resolved_cache.insert(
object_type_name.clone(),
AnalyzedSchema {
name: object_type_name.clone(),
original: serde_json::to_value(schema).unwrap_or(Value::Null),
schema_type: object_type,
dependencies: dependencies.clone(),
nullable: false,
description: schema.details().description.clone(),
default: None,
},
);
dependencies.insert(object_type_name.clone());
Ok(SchemaRef {
target: object_type_name,
nullable: false,
})
}
_ => Ok(SchemaRef {
target: self
.type_mapper
.map(member_type, schema.details())
.rust_type,
nullable: false,
}),
}
}

fn analyze_property_schema_with_context(
&mut self,
schema: &Schema,
Expand Down Expand Up @@ -2181,6 +2281,67 @@ impl SchemaAnalyzer {
}
}

// Genuine multi-scalar `type: [X, Y]` union (not the 3.1 nullable
// shorthand `[X, "null"]`, which `schema_type()` already collapses).
// Give it a named enum, same as an anyOf/oneOf union property below.
if let Some(non_null_types) = schema.non_null_schema_types() {
let context_name = self
.current_schema_name
.clone()
.unwrap_or_else(|| "Unknown".to_string());
let prop_pascal = property_name
.map(|name| self.to_pascal_case(name))
.unwrap_or_default();
let mut union_type_name = format!("{context_name}{prop_pascal}");
if self.schemas.contains_key(&union_type_name)
|| self.resolved_cache.contains_key(&union_type_name)
{
let mut suffix = 2;
loop {
let candidate = format!("{union_type_name}Union{suffix}");
if !self.schemas.contains_key(&candidate)
&& !self.resolved_cache.contains_key(&candidate)
{
union_type_name = candidate;
break;
}
suffix += 1;
if suffix > 1000 {
break;
}
}
}

let details = schema.details();
let mut variants = Vec::with_capacity(non_null_types.len());
for t in non_null_types {
variants.push(self.build_typed_multi_union_variant(
t,
schema,
&union_type_name,
dependencies,
)?);
}

self.resolved_cache.insert(
union_type_name.clone(),
AnalyzedSchema {
name: union_type_name.clone(),
original: serde_json::to_value(schema).unwrap_or(Value::Null),
schema_type: SchemaType::Union { variants },
dependencies: HashSet::new(),
nullable: false,
description: details.description.clone(),
default: None,
},
);

dependencies.insert(union_type_name.clone());
return Ok(SchemaType::Reference {
target: union_type_name,
});
}

if let Some(schema_type) = schema.schema_type() {
match schema_type {
OpenApiSchemaType::String => {
Expand Down
24 changes: 24 additions & 0 deletions src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,9 @@ impl Schema {
/// Get the schema type if explicitly set. For `Schema::TypedMulti` the
/// "primary" non-null type is returned; if the array contained only `null`
/// then `Some(&SchemaType::Null)` is returned.
///
/// If the other non-null variants are important, consider where you should
/// instead use [non_null_schema_types][Self::non_null_schema_types].
pub fn schema_type(&self) -> Option<&SchemaType> {
match self {
Schema::Typed { schema_type, .. } => Some(schema_type),
Expand All @@ -675,6 +678,27 @@ impl Schema {
}
}

/// Gets all non-null types from `type: [...]` (unlike
/// [Schema::schema_type] that handles the null value). Returns `None` if
/// the given [Schema::TypedMulti] has either only one non-null value, or if
/// this isn't a `Schema::TypedMulti`.
///
/// This also removes duplicates.
pub fn non_null_schema_types(&self) -> Option<Vec<SchemaType>> {
match self {
Schema::TypedMulti { schema_types, .. } => {
let mut non_null = Vec::new();
for t in schema_types {
if *t != SchemaType::Null && !non_null.contains(t) {
non_null.push(t.clone());
}
}
(non_null.len() > 1).then_some(non_null)
}
_ => None,
}
}

/// True when the schema's type set explicitly contains `null`.
/// (3.1 canonical nullability via `type: ["X", "null"]`.)
pub fn type_array_contains_null(&self) -> bool {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
source: src/test_helpers.rs
expression: "&generated_code"
---
//! Generated types from OpenAPI specification
//!
//! This file contains all the generated types for the API.
//! Do not edit manually - regenerate using the appropriate script.
#![allow(clippy::large_enum_variant)]
#![allow(clippy::format_in_format_args)]
#![allow(clippy::let_unit_value)]
#![allow(unreachable_patterns)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct Widget {
#[serde(skip_serializing_if = "Option::is_none")]
pub tags_or_tag: Option<WidgetTagsOrTag>,
}
///Array variant in union
pub type WidgetTagsOrTagArray = Vec<String>;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum WidgetTagsOrTag {
WidgetTagsOrTagArray(WidgetTagsOrTagArray),
String(String),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
source: src/test_helpers.rs
expression: "&generated_code"
---
//! Generated types from OpenAPI specification
//!
//! This file contains all the generated types for the API.
//! Do not edit manually - regenerate using the appropriate script.
#![allow(clippy::large_enum_variant)]
#![allow(clippy::format_in_format_args)]
#![allow(clippy::let_unit_value)]
#![allow(unreachable_patterns)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct Widget {
#[serde(skip_serializing_if = "Option::is_none")]
pub maybe_name: Option<String>,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
source: src/test_helpers.rs
expression: "&generated_code"
---
//! Generated types from OpenAPI specification
//!
//! This file contains all the generated types for the API.
//! Do not edit manually - regenerate using the appropriate script.
#![allow(clippy::large_enum_variant)]
#![allow(clippy::format_in_format_args)]
#![allow(clippy::let_unit_value)]
#![allow(unreachable_patterns)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum IdOrCode {
Integer(i64),
String(String),
}
Loading
Loading