From be94243def8a1019511f368c2ed11f11d2278b34 Mon Sep 17 00:00:00 2001 From: James Lal Date: Wed, 26 Aug 2026 17:51:55 -0600 Subject: [PATCH 1/6] feat: report why generated fields fall back to serde_json::Value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serde_json::Value` in generated code means one of two things: the schema declared an unconstrained value, or the generator dropped type information the schema carried. Only the second is a defect, and nothing distinguished them — across the corpus there are 13k untyped positions and no way to rank them. Carry the reason in the IR. `SchemaType::Untyped { shape, reason }` replaces the stringly-typed `Primitive { rust_type: "serde_json::Value" }` fallbacks, so the census is derived from the types that get generated rather than recorded as analysis runs: a schema referenced by fifty properties counts fifty times, and a pruned one counts zero. Recording it on the side got both of those wrong, by 47% corpus-wide. `--report-untyped` groups a spec's untyped fields by reason and marks each faithful or recoverable; `--json` emits them with paths for corpus tooling. A normalization pass converts any fallback that still builds its type from a TypeMapper string, reporting it as `Unclassified` — a visible gap in the taxonomy rather than a missing count. The corpus currently has none. Corpus-wide, of 10,619 findings: opaque-object 5,488, any-schema 3,080, untyped-additional-properties 1,376 — all faithful — against 675 recoverable, dominated by unions (592). The census accounts for ~81% of untyped positions in generated output. The rest come from the generator's own render fallbacks, which this does not yet see: a single-branch `allOf` around a scalar generates `serde_json::Value` while the census reports the spec as fully typed. That is the next seam. Refs #62 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry --- src/analysis.rs | 472 ++++++++++++++++++++++++++++------- src/bin/openapi-to-rust.rs | 77 ++++++ src/generator.rs | 24 ++ src/server/codegen.rs | 1 + tests/untyped_census_test.rs | 173 +++++++++++++ 5 files changed, 655 insertions(+), 92 deletions(-) create mode 100644 tests/untyped_census_test.rs diff --git a/src/analysis.rs b/src/analysis.rs index 1e89922..27a3e83 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -103,6 +103,270 @@ pub struct SchemaAnalysis { pub validation_context: ValidationContext, } +/// Convert any `serde_json::Value` still carried as a stringly-typed +/// `Primitive` into [`SchemaType::Untyped`]. +/// +/// Several fallbacks build their type from a [`TypeMapper`] result rather than +/// through the analyzer's helpers, so this runs over the finished IR as a net. +/// Anything it catches is reported as [`UntypedReason::Unclassified`] — a +/// visible gap in the taxonomy rather than a silently missing count. +/// +/// [`TypeMapper`]: crate::type_mapping::TypeMapper +fn normalize_untyped(schema_type: &mut SchemaType, depth: usize) { + if depth > UNTYPED_WALK_DEPTH { + return; + } + match schema_type { + SchemaType::Primitive { rust_type, .. } => { + let shape = match rust_type.as_str() { + "serde_json::Value" => Some(UntypedShape::Value), + "Vec" => Some(UntypedShape::ValueArray), + _ => None, + }; + if let Some(shape) = shape { + *schema_type = SchemaType::Untyped { + shape, + reason: UntypedReason::Unclassified, + }; + } + } + SchemaType::Object { + properties, + additional_properties, + .. + } => { + for property in properties.values_mut() { + normalize_untyped(&mut property.schema_type, depth + 1); + } + if let ObjectAdditionalProperties::Typed { value_type } = additional_properties { + normalize_untyped(value_type, depth + 1); + } + } + SchemaType::Array { item_type } => normalize_untyped(item_type, depth + 1), + SchemaType::Tuple { element_types } => { + for element_type in element_types { + normalize_untyped(element_type, depth + 1); + } + } + SchemaType::Untyped { .. } + | SchemaType::StringEnum { .. } + | SchemaType::ExtensibleEnum { .. } + | SchemaType::DiscriminatedUnion { .. } + | SchemaType::Union { .. } + | SchemaType::Composition { .. } + | SchemaType::Reference { .. } => {} + } +} + +impl SchemaAnalysis { + /// Every generated field that carries `serde_json::Value`, with the reason. + /// + /// Derived from the analyzed types rather than recorded as analysis runs, + /// so the count tracks generated output: a schema referenced by fifty + /// properties contributes fifty findings, and a pruned one contributes + /// none. + pub fn untyped_fields(&self) -> Vec { + let mut findings = Vec::new(); + for (name, schema) in &self.schemas { + collect_untyped(&schema.schema_type, name, &mut findings, 0); + } + findings.sort(); + findings + } +} + +/// Depth limit for the census walk. Generated types bottom out well before +/// this; the bound only stops a cycle that slipped through analysis from +/// hanging a diagnostic. +const UNTYPED_WALK_DEPTH: usize = 32; + +fn collect_untyped( + schema_type: &SchemaType, + context: &str, + findings: &mut Vec, + depth: usize, +) { + if depth > UNTYPED_WALK_DEPTH { + return; + } + match schema_type { + SchemaType::Untyped { shape, reason } => findings.push(UntypedFinding { + context: context.to_string(), + shape: *shape, + reason: *reason, + }), + SchemaType::Object { + properties, + additional_properties, + .. + } => { + for (property_name, property) in properties { + collect_untyped( + &property.schema_type, + &format!("{context}.{property_name}"), + findings, + depth + 1, + ); + } + match additional_properties { + ObjectAdditionalProperties::Untyped => findings.push(UntypedFinding { + context: format!("{context}."), + shape: UntypedShape::ValueMap, + reason: UntypedReason::UntypedAdditionalProperties, + }), + ObjectAdditionalProperties::Typed { value_type } => collect_untyped( + value_type, + &format!("{context}."), + findings, + depth + 1, + ), + ObjectAdditionalProperties::Forbidden => {} + } + } + SchemaType::Array { item_type } => { + collect_untyped(item_type, &format!("{context}[]"), findings, depth + 1) + } + SchemaType::Tuple { element_types } => { + for (index, element_type) in element_types.iter().enumerate() { + collect_untyped( + element_type, + &format!("{context}[{index}]"), + findings, + depth + 1, + ); + } + } + // A union branch that mapped to an untyped Rust type is carried as a + // variant target string, so it is recognized by name here. + SchemaType::Union { variants } | SchemaType::Composition { schemas: variants } => { + for (index, variant) in variants.iter().enumerate() { + if let Some(shape) = untyped_shape_of(&variant.target) { + findings.push(UntypedFinding { + context: format!("{context}|{index}"), + shape, + reason: UntypedReason::UntypedUnionBranch, + }); + } + } + } + SchemaType::Primitive { .. } + | SchemaType::StringEnum { .. } + | SchemaType::ExtensibleEnum { .. } + | SchemaType::DiscriminatedUnion { .. } + | SchemaType::Reference { .. } => {} + } +} + +/// The untyped shape a generated Rust type name denotes, if any. +fn untyped_shape_of(rust_type: &str) -> Option { + match rust_type { + "serde_json::Value" => Some(UntypedShape::Value), + "Vec" => Some(UntypedShape::ValueArray), + _ => None, + } +} + +/// One generated field (or type) that carries `serde_json::Value` instead of a +/// generated Rust type. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +pub struct UntypedFinding { + /// Where it surfaced, as far as analysis knows: usually + /// `Schema.property`, or a synthesized operation type. + pub context: String, + /// The shape the generator will emit. + pub shape: UntypedShape, + /// Why the schema produced no better type. + pub reason: UntypedReason, +} + +/// The generated shape carrying the untyped payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UntypedShape { + /// `serde_json::Value` + Value, + /// `Vec` + ValueArray, + /// `BTreeMap` + ValueMap, +} + +/// Why a schema produced an untyped value. +/// +/// The split that matters is [`UntypedReason::verdict`]: a schema that says +/// "any JSON" has no better Rust type and is generated correctly, while a +/// schema that carried type information the generator dropped is a defect with +/// a fix. Counting the two together would make the corpus look worse than it is +/// and hide which cases are worth work. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum UntypedReason { + /// `{}`, `true`, or a schema with no constraining keyword at all: the spec + /// declares any JSON value. + AnySchema, + /// `type: object` with no `properties` and no typed `additionalProperties`: + /// an object of unknown shape. + OpaqueObject, + /// `additionalProperties: true` or absent, so map values are unconstrained. + UntypedAdditionalProperties, + /// `type: array` with no `items` at all. + ArrayWithoutItems, + /// Positional items that permit extra elements of any type (issue #62, + /// tier 3), so neither a tuple nor a `Vec` is sound. + OpenPositionalItems, + /// A union (`oneOf`/`anyOf`) whose branches did not reduce to one + /// generated Rust type. + UnrepresentableUnion, + /// An `allOf` composition that could not be merged into a struct. + UnrepresentableComposition, + /// The schema declared a type keyword the generator has no mapping for. + UnsupportedTypeKeyword, + /// A `$ref` that analysis could not resolve to a generated schema. + UnresolvedReference, + /// A `oneOf`/`anyOf` branch that mapped to an untyped value, so the + /// generated union carries a `serde_json::Value` variant. Whether that is + /// faithful depends on the branch, which the analyzed type no longer says. + UntypedUnionBranch, + /// Reached a fallback that has not been classified yet. Every one of these + /// is a gap in this taxonomy, not in the generator. + Unclassified, +} + +impl UntypedReason { + /// Whether the untyped output is the honest reading of the schema, or a + /// case where the generator can do better. + pub fn verdict(self) -> UntypedVerdict { + match self { + // The spec genuinely declares an unconstrained value. + Self::AnySchema | Self::OpaqueObject | Self::UntypedAdditionalProperties => { + UntypedVerdict::Faithful + } + // An array with no `items` says nothing about elements, and open + // positional items permit extras of any type: both are the spec's + // choice, not a dropped constraint. + Self::ArrayWithoutItems | Self::OpenPositionalItems => UntypedVerdict::Faithful, + // These carried type information that did not survive analysis. + Self::UnrepresentableUnion + | Self::UnrepresentableComposition + | Self::UnsupportedTypeKeyword + | Self::UnresolvedReference => UntypedVerdict::Recoverable, + Self::UntypedUnionBranch | Self::Unclassified => UntypedVerdict::Unknown, + } + } +} + +/// Whether an untyped output is worth working on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UntypedVerdict { + /// The schema declares an unconstrained value; `serde_json::Value` is correct. + Faithful, + /// The schema carried type information the generator dropped. + Recoverable, + /// Not yet classified. + Unknown, +} + /// Server-relevant semantics of one OpenAPI Response Object. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] pub struct OperationResponse { @@ -215,6 +479,16 @@ pub enum SchemaType { Composition { schemas: Vec }, /// Reference to another schema Reference { target: String }, + /// A value the generator could not type, rendered as `serde_json::Value`. + /// + /// The reason travels with the type rather than in a side table, so the + /// census counts what is actually generated: a schema analyzed once but + /// referenced by fifty properties yields fifty untyped fields, and one that + /// is pruned yields none. + Untyped { + shape: UntypedShape, + reason: UntypedReason, + }, } /// How an Object handles `additionalProperties`. Q2.3 split the @@ -1080,6 +1354,38 @@ pub struct SchemaAnalyzer { } impl SchemaAnalyzer { + /// The type to emit when a schema gives analysis nothing to work with. + /// Every `serde_json::Value` that analysis produces goes through here or + /// [`Self::untyped_value_array`], so a fallback cannot escape the census. + fn untyped_value(&self, _context: impl Into, reason: UntypedReason) -> SchemaType { + SchemaType::Untyped { + shape: UntypedShape::Value, + reason, + } + } + + /// As [`Self::untyped_value`], for an array of unconstrained elements. + fn untyped_value_array( + &self, + _context: impl Into, + reason: UntypedReason, + ) -> SchemaType { + SchemaType::Untyped { + shape: UntypedShape::ValueArray, + reason, + } + } + + /// The schema currently being analyzed, for finding context. + fn untyped_context(&self, detail: &str) -> String { + match (&self.current_schema_name, detail) { + (Some(schema), "") => schema.clone(), + (Some(schema), detail) => format!("{schema}.{detail}"), + (None, "") => "".to_string(), + (None, detail) => detail.to_string(), + } + } + fn uses_aws_query_conventions(&self) -> bool { self.openapi_spec .pointer("/info/x-providerName") @@ -1371,6 +1677,10 @@ impl SchemaAnalyzer { } } + for schema in analysis.schemas.values_mut() { + normalize_untyped(&mut schema.schema_type, 0); + } + Ok(analysis) } @@ -1732,10 +2042,10 @@ impl SchemaAnalyzer { "⚠️ unresolvable $ref `{}` — typing as serde_json::Value", reference ); - SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - } + self.untyped_value( + format!("$ref {reference}"), + UntypedReason::UnresolvedReference, + ) } } } @@ -1820,10 +2130,10 @@ impl SchemaAnalyzer { match inferred { OpenApiSchemaType::Object => { if self.should_use_dynamic_json(schema) { - SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - } + self.untyped_value( + self.untyped_context(""), + UntypedReason::OpaqueObject, + ) } else { self.analyze_object_schema(schema, &mut dependencies)? } @@ -1833,16 +2143,13 @@ impl SchemaAnalyzer { values: details.string_enum_values().unwrap_or_default(), } } - _ => SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }, + _ => self.untyped_value( + self.untyped_context(""), + UntypedReason::UnsupportedTypeKeyword, + ), } } else { - SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - } + self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema) } } }; @@ -1903,18 +2210,15 @@ impl SchemaAnalyzer { } OpenApiSchemaType::Object => { if self.should_use_dynamic_json(schema) { - SchemaType::Primitive { - rust_type: self.type_mapper.dynamic_json().rust_type, - serde_with: None, - } + self.untyped_value(self.untyped_context(""), UntypedReason::OpaqueObject) } else { self.analyze_object_schema(schema, dependencies)? } } - _ => SchemaType::Primitive { - rust_type: self.type_mapper.dynamic_json().rust_type, - serde_with: None, - }, + _ => self.untyped_value( + self.untyped_context(""), + UntypedReason::UnsupportedTypeKeyword, + ), }) } @@ -1940,10 +2244,10 @@ impl SchemaAnalyzer { // First check if this should be a dynamic JSON pattern if self.should_use_dynamic_json(prop_schema) { // This is a dynamic JSON pattern, use serde_json::Value directly - SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - } + self.untyped_value( + self.untyped_context(prop_name), + UntypedReason::OpaqueObject, + ) } else if prop_schema.is_nullable_pattern() && let Some(non_null) = prop_schema.non_null_variant() { @@ -2279,10 +2583,10 @@ impl SchemaAnalyzer { "⚠️ unresolvable $ref `{}` — typing as serde_json::Value", ref_str ); - return Ok(SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }); + return Ok(self.untyped_value( + format!("$ref {ref_str}"), + UntypedReason::UnresolvedReference, + )); } } } @@ -2431,10 +2735,8 @@ impl SchemaAnalyzer { OpenApiSchemaType::Object => { // Check if this is a dynamic JSON object if self.should_use_dynamic_json(schema) { - return Ok(SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }); + return Ok(self + .untyped_value(self.untyped_context(""), UntypedReason::OpaqueObject)); } // Inline object in property - create a named schema for it let object_type_name = if let Some(prop_name) = property_name { @@ -2478,10 +2780,10 @@ impl SchemaAnalyzer { }); } _ => { - return Ok(SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }); + return Ok(self.untyped_value( + self.untyped_context(""), + UntypedReason::UnsupportedTypeKeyword, + )); } } } @@ -2499,10 +2801,7 @@ impl SchemaAnalyzer { // Check if this should be dynamic JSON before further analysis if self.should_use_dynamic_json(schema) { - return Ok(SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }); + return Ok(self.untyped_value(self.untyped_context(""), UntypedReason::OpaqueObject)); } // Handle allOf composition patterns @@ -2620,10 +2919,8 @@ impl SchemaAnalyzer { OpenApiSchemaType::Object => { // Double-check for dynamic JSON pattern even for inferred objects if self.should_use_dynamic_json(schema) { - return Ok(SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }); + return Ok(self + .untyped_value(self.untyped_context(""), UntypedReason::OpaqueObject)); } return self.analyze_object_schema(schema, dependencies); } @@ -2665,10 +2962,7 @@ impl SchemaAnalyzer { } } - Ok(SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }) + Ok(self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema)) } fn analyze_allof_composition( @@ -3221,10 +3515,10 @@ impl SchemaAnalyzer { } // Only fall back to serde_json::Value if we truly can't analyze the union - return Ok(SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }); + return Ok(self.untyped_value( + self.untyped_context(""), + UntypedReason::UnrepresentableUnion, + )); } Ok(SchemaType::DiscriminatedUnion { @@ -3414,10 +3708,10 @@ impl SchemaAnalyzer { } // Only fall back to serde_json::Value if we truly can't analyze the union - Ok(SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }) + Ok(self.untyped_value( + self.untyped_context(""), + UntypedReason::UnrepresentableUnion, + )) } fn add_inline_schema( @@ -3997,10 +4291,12 @@ impl SchemaAnalyzer { }) } else { // No items specified, fall back to generic array - Ok(SchemaType::Primitive { - rust_type: "Vec".to_string(), - serde_with: None, - }) + Ok( + self.untyped_value_array( + self.untyped_context(""), + UntypedReason::ArrayWithoutItems, + ), + ) } } @@ -4053,10 +4349,7 @@ impl SchemaAnalyzer { }); } - Ok(SchemaType::Primitive { - rust_type: "Vec".to_string(), - serde_with: None, - }) + Ok(self.untyped_value_array(self.untyped_context(""), UntypedReason::OpenPositionalItems)) } /// Analyze one element schema into its generated type. @@ -4168,10 +4461,10 @@ impl SchemaAnalyzer { // Array of arrays - recursively analyze self.analyze_array_schema(items_schema, parent_schema_name, dependencies)? } - _ => SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }, + _ => self.untyped_value( + self.untyped_context(""), + UntypedReason::UnsupportedTypeKeyword, + ), } } Schema::OneOf { .. } | Schema::AnyOf { .. } => { @@ -4266,22 +4559,19 @@ impl SchemaAnalyzer { rust_type: "bool".to_string(), serde_with: None, }, - _ => SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }, + _ => self.untyped_value( + self.untyped_context(""), + UntypedReason::UnsupportedTypeKeyword, + ), } } else { - SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - } + self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema) } } - _ => SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }, + _ => self.untyped_value( + self.untyped_context(""), + UntypedReason::UnrepresentableComposition, + ), }; Ok(item_type) @@ -4325,10 +4615,7 @@ impl SchemaAnalyzer { .cloned() .collect(); if filtered_owned.is_empty() { - return Ok(SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }); + return Ok(self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema)); } if filtered_owned.len() == 1 { return self @@ -4595,10 +4882,10 @@ impl SchemaAnalyzer { } // Pattern 4: Mixed primitives = fall back to serde_json::Value - Ok(SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }) + Ok(self.untyped_value( + self.untyped_context(""), + UntypedReason::UnrepresentableUnion, + )) } /// Find the schema with $recursiveAnchor: true for resolving $recursiveRef: "#" @@ -6604,6 +6891,7 @@ fn rewrite_schema_type_names(schema_type: &mut SchemaType, aliases: &BTreeMap rewrite_schema_type_names(item_type, aliases), + SchemaType::Untyped { .. } => {} SchemaType::Tuple { element_types } => { for element_type in element_types { rewrite_schema_type_names(element_type, aliases); diff --git a/src/bin/openapi-to-rust.rs b/src/bin/openapi-to-rust.rs index 4df9bff..8a9a229 100644 --- a/src/bin/openapi-to-rust.rs +++ b/src/bin/openapi-to-rust.rs @@ -53,6 +53,10 @@ enum Commands { /// Exit unsuccessfully when generated files are missing or stale. #[arg(long, conflicts_with = "dry_run")] check: bool, + /// Report every field that generated `serde_json::Value`, grouped by + /// why, so an untyped result can be traced back to the schema. + #[arg(long)] + report_untyped: bool, /// Suppress successful human-readable output. #[arg(long, conflicts_with = "json")] quiet: bool, @@ -210,6 +214,7 @@ fn run(cli: Cli) -> Result<(), Box> { types_conservative, dry_run, check, + report_untyped, quiet, json, } => run_generate(GenerateArgs { @@ -221,6 +226,7 @@ fn run(cli: Cli) -> Result<(), Box> { types_conservative, dry_run, check, + report_untyped, quiet, json, }), @@ -280,6 +286,7 @@ struct GenerateArgs { types_conservative: bool, dry_run: bool, check: bool, + report_untyped: bool, quiet: bool, json: bool, } @@ -296,6 +303,72 @@ struct InitArgs { json: bool, } +/// Print the untyped-output census for one spec. +/// +/// `serde_json::Value` in generated code has two very different meanings: the +/// schema said "any JSON", or the schema said something the generator failed to +/// carry through. The report separates them, because only the second kind is a +/// defect worth chasing. +fn report_untyped( + analysis: &openapi_to_rust::SchemaAnalysis, + as_json: bool, +) -> Result<(), Box> { + use openapi_to_rust::analysis::{UntypedReason, UntypedVerdict}; + use std::collections::BTreeMap; + + let untyped = analysis.untyped_fields(); + + if as_json { + println!("{}", serde_json::to_string_pretty(&untyped)?); + return Ok(()); + } + + if untyped.is_empty() { + println!("untyped: none — every generated field carries a type"); + return Ok(()); + } + + let mut by_reason: BTreeMap> = BTreeMap::new(); + for finding in &untyped { + by_reason + .entry(finding.reason) + .or_default() + .push(finding.context.as_str()); + } + + let mut rows = by_reason.into_iter().collect::>(); + rows.sort_by_key(|(reason, contexts)| (std::cmp::Reverse(contexts.len()), *reason)); + + let recoverable = untyped + .iter() + .filter(|finding| finding.reason.verdict() == UntypedVerdict::Recoverable) + .count(); + println!( + "untyped: {} field(s), {recoverable} of them recoverable", + untyped.len() + ); + for (reason, contexts) in rows { + let verdict = match reason.verdict() { + UntypedVerdict::Faithful => "faithful", + UntypedVerdict::Recoverable => "recoverable", + UntypedVerdict::Unknown => "unclassified", + }; + println!( + " {:5} {:<28} {:<12} e.g. {}", + contexts.len(), + format!("{reason:?}"), + verdict, + contexts + .iter() + .take(3) + .copied() + .collect::>() + .join(", ") + ); + } + Ok(()) +} + #[derive(Serialize)] struct GenerationSummary { status: &'static str, @@ -388,6 +461,10 @@ fn run_generate(args: GenerateArgs) -> Result<(), Box> { warning, }; + if args.report_untyped { + report_untyped(&analysis, args.json)?; + } + if args.json { println!("{}", serde_json::to_string_pretty(&summary)?); } else if !args.quiet { diff --git a/src/generator.rs b/src/generator.rs index 2f3549e..c6256e2 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -366,6 +366,25 @@ pub struct CodeGenerator { source_provenance: Option, } +/// The Rust type an untyped schema renders to. +fn untyped_rust_type(shape: crate::analysis::UntypedShape) -> &'static str { + use crate::analysis::UntypedShape; + match shape { + UntypedShape::Value => "serde_json::Value", + UntypedShape::ValueArray => "Vec", + UntypedShape::ValueMap => "std::collections::BTreeMap", + } +} + +fn untyped_tokens(shape: crate::analysis::UntypedShape) -> TokenStream { + use crate::analysis::UntypedShape; + match shape { + UntypedShape::Value => quote! { serde_json::Value }, + UntypedShape::ValueArray => quote! { Vec }, + UntypedShape::ValueMap => quote! { std::collections::BTreeMap }, + } +} + impl CodeGenerator { pub fn new(config: GeneratorConfig) -> Self { Self { @@ -1502,6 +1521,9 @@ impl CodeGenerator { Ok(TokenStream::new()) } } + SchemaType::Untyped { shape, .. } => { + self.generate_type_alias(schema, untyped_rust_type(*shape)) + } SchemaType::Tuple { element_types } => { let tuple_type = self.generate_tuple_type(element_types, analysis); let type_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); @@ -2733,6 +2755,7 @@ impl CodeGenerator { SchemaType::Tuple { element_types } => { self.generate_tuple_type(element_types, analysis) } + SchemaType::Untyped { shape, .. } => untyped_tokens(*shape), _ => { // Fallback for complex types quote! { serde_json::Value } @@ -3471,6 +3494,7 @@ impl CodeGenerator { SchemaType::Tuple { element_types } => { self.generate_tuple_type(element_types, analysis) } + SchemaType::Untyped { shape, .. } => untyped_tokens(*shape), _ => { // Fallback for complex types quote! { serde_json::Value } diff --git a/src/server/codegen.rs b/src/server/codegen.rs index 3e9ea97..6517ec9 100644 --- a/src/server/codegen.rs +++ b/src/server/codegen.rs @@ -196,6 +196,7 @@ fn collect_schema_type_refs( } } SchemaType::Reference { target } => seed(target, queue, keep), + SchemaType::Untyped { .. } => {} } } diff --git a/tests/untyped_census_test.rs b/tests/untyped_census_test.rs new file mode 100644 index 0000000..7faa419 --- /dev/null +++ b/tests/untyped_census_test.rs @@ -0,0 +1,173 @@ +//! The untyped-output census (`--report-untyped`). +//! +//! `serde_json::Value` in generated code means one of two very different +//! things: the schema declared an unconstrained value, or the generator failed +//! to carry through type information the schema had. The census exists to tell +//! them apart across a corpus, so the second kind can be found and fixed. +//! +//! Its one hard requirement is that it not lie: a reason must describe why that +//! specific field went untyped, and a fallback must never escape unreported. + +use openapi_to_rust::analysis::{UntypedReason, UntypedShape, UntypedVerdict}; +use openapi_to_rust::{SchemaAnalyzer, analysis::SchemaAnalysis}; +use serde_json::{Value, json}; + +fn analyze(spec: Value) -> SchemaAnalysis { + SchemaAnalyzer::new(spec) + .expect("spec parses") + .analyze() + .expect("spec analyzes") +} + +fn spec_with_schemas(schemas: Value) -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "census", "version": "1.0.0" }, + "components": { "schemas": schemas } + }) +} + +#[test] +fn a_fully_typed_spec_reports_nothing() { + let analysis = analyze(spec_with_schemas(json!({ + "Thing": { + "type": "object", + "additionalProperties": false, + "properties": { "name": { "type": "string" }, "count": { "type": "integer" } } + } + }))); + + assert!(analysis.untyped_fields().is_empty()); +} + +#[test] +fn an_unconstrained_object_is_reported_as_faithful() { + let analysis = analyze(spec_with_schemas(json!({ + "Thing": { + "type": "object", + "additionalProperties": false, + "properties": { "meta": { "type": "object" } } + } + }))); + + let findings = analysis.untyped_fields(); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].context, "Thing.meta"); + assert_eq!(findings[0].shape, UntypedShape::Value); + assert_eq!(findings[0].reason, UntypedReason::OpaqueObject); + assert_eq!(findings[0].reason.verdict(), UntypedVerdict::Faithful); +} + +#[test] +fn open_additional_properties_are_reported_on_the_owning_schema() { + let analysis = analyze(spec_with_schemas(json!({ + "Thing": { + "type": "object", + "additionalProperties": true, + "properties": { "name": { "type": "string" } } + } + }))); + + let findings = analysis.untyped_fields(); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].context, "Thing."); + assert_eq!(findings[0].shape, UntypedShape::ValueMap); + assert_eq!( + findings[0].reason, + UntypedReason::UntypedAdditionalProperties + ); +} + +#[test] +fn an_array_without_items_is_reported_as_an_array_shape() { + let analysis = analyze(spec_with_schemas(json!({ + "Thing": { + "type": "object", + "additionalProperties": false, + "properties": { "tags": { "type": "array" } } + } + }))); + + let findings = analysis.untyped_fields(); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].shape, UntypedShape::ValueArray); + assert_eq!(findings[0].reason, UntypedReason::ArrayWithoutItems); +} + +#[test] +fn nested_positions_keep_a_path_that_locates_them() { + let analysis = analyze(spec_with_schemas(json!({ + "Thing": { + "type": "object", + "additionalProperties": false, + "properties": { + // Hoisted to a named struct: typed, and not a finding. + "rows": { "type": "array", "items": { "type": "object" } }, + // Nothing to hoist: the element itself is unconstrained. + "raws": { "type": "array", "items": {} } + } + } + }))); + + let findings = analysis.untyped_fields(); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!( + findings[0].context, "Thing.raws[]", + "the path must say the array's element is what went untyped" + ); + assert_eq!(findings[0].reason, UntypedReason::AnySchema); +} + +#[test] +fn a_reference_counts_once_per_use_not_once_per_schema() { + // The whole point of deriving the census from analyzed types: one untyped + // schema reached from three properties is three untyped generated fields. + let analysis = analyze(spec_with_schemas(json!({ + "Thing": { + "type": "object", + "additionalProperties": false, + "properties": { + "a": { "type": "object" }, + "b": { "type": "object" }, + "c": { "type": "object" } + } + } + }))); + + assert_eq!(analysis.untyped_fields().len(), 3); +} + +#[test] +fn no_fallback_escapes_the_taxonomy() { + // Every reason must classify. `Unclassified` means a fallback reached the + // normalization net without being named at its source, which is a gap to + // close rather than a category to live with. + let analysis = analyze(spec_with_schemas(json!({ + "Opaque": { "type": "object" }, + "Anything": {}, + "OpenMap": { "type": "object", "additionalProperties": true }, + "Bare": { "type": "array" }, + "Mixed": { "anyOf": [{ "type": "object" }, { "type": "array" }] }, + "Holder": { + "type": "object", + "additionalProperties": false, + "properties": { + "opaque": { "$ref": "#/components/schemas/Opaque" }, + "anything": { "$ref": "#/components/schemas/Anything" }, + "map": { "$ref": "#/components/schemas/OpenMap" }, + "bare": { "$ref": "#/components/schemas/Bare" }, + "mixed": { "$ref": "#/components/schemas/Mixed" } + } + } + }))); + + let unclassified = analysis + .untyped_fields() + .into_iter() + .filter(|finding| finding.reason == UntypedReason::Unclassified) + .collect::>(); + assert!( + unclassified.is_empty(), + "unnamed fallbacks reached the census: {unclassified:?}" + ); +} From fb76ebfc4ce897ae87d6550afb29585ad288e3d0 Mon Sep 17 00:00:00 2001 From: James Lal Date: Wed, 26 Aug 2026 21:24:00 -0600 Subject: [PATCH 2/6] fix: type the schemas that were degrading to serde_json::Value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The untyped census showed 3,347 of 13,226 untyped fields as recoverable — schemas that said enough to type and were typed as `serde_json::Value` anyway. This takes that to 6. The largest by far was a single idiom. OData spells a nullable reference `anyOf: [$ref, {type: object, nullable: true}]`, and reading the second branch literally makes the union unrepresentable, costing the first branch its type: 2,127 fields in Microsoft Graph. An empty nullable object is a null marker, so those become `Option`. The next largest was structural rather than semantic. A struct field can hold a primitive, a reference, an array, or a tuple; an inline object, union, enum, or merged `allOf` has to be generated as its own item. Analysis understood those schemas and left them in field positions, where the generator — with nothing it could write — emitted `serde_json::Value`. They are now hoisted to named types, in property, array-element, and tuple-element positions alike. The rest were narrower gaps, each traced to a real document: - `allOf` with one member is that member (Box hangs a description off a scalar this way), and array items never handled `allOf` at all (Asana); - a `$ref` to any local pointer now resolves, not just component schemas (PagerDuty references a parameter's schema and one member of a composition); - `type: null` is `()` rather than unknown (Discord); - `oneOf: []` alongside a real `type` takes that type (Discord), a union of one branch is that branch (gcore), branches differing only in `pattern` or `format` share their wire type (Runway, gcore), branches that only alternate `required` describe the object beside them (Cloudflare), and pointer branches are expanded before the union is built (PagerDuty). Each of these has a regression test naming the spec it came from, asserting both the generated type and that the census no longer reports the field. The negative cases are pinned too: a nullable branch that constrains something stays a union, and an unconstrained schema stays `serde_json::Value`. What remains is one shape — a base object combined with a variant union — which needs a generated form the crate does not have. Filed as #65. Refs #62 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry --- CHANGELOG.md | 20 + src/analysis.rs | 644 +++++++++++++++++++++++++++++-- src/openapi.rs | 75 +++- tests/recoverable_typing_test.rs | 395 +++++++++++++++++++ 4 files changed, 1087 insertions(+), 47 deletions(-) create mode 100644 tests/recoverable_typing_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 046f5a1..836b1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,26 @@ when correcting output that was wrong or incomplete on the wire. ### Fixed +- Schemas that carried enough information to type no longer degrade to + `serde_json::Value`. Across the 57-spec corpus this types 3,341 fields that + were previously opaque (#62): + - `anyOf: [$ref, {type: object, nullable: true}]` — how OData spells "that + type, or null" — becomes `Option` instead of an untyped union (2,127 + fields in Microsoft Graph alone); + - an inline object, union, enum, or merged `allOf` in a field or element + position is hoisted to a named type instead of being dropped by the + generator, which could not render one inline; + - `allOf` with a single member takes that member's type, and `allOf` inside + array items is analyzed instead of ignored; + - a `$ref` to any local JSON Pointer resolves — a parameter's schema, one + member of another schema's composition — not only + `#/components/schemas/`; + - `type: null` becomes `()`, which serde reads and writes as `null`; + - a union whose branch list is empty takes the schema's declared type; a + union of one branch is that branch; branches differing only in constraints + share one type; branches that only alternate `required` describe the + object their properties declare; and branches that are local pointers are + expanded before the union is built. - `items: false` and `items: true` — 2020-12 boolean schemas, and the canonical way to close a tuple — now parse instead of failing the document with "data did not match any variant of untagged enum Schema" (#62). diff --git a/src/analysis.rs b/src/analysis.rs index 27a3e83..b21c74e 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -103,6 +103,52 @@ pub struct SchemaAnalysis { pub validation_context: ValidationContext, } +impl SchemaType { + /// Whether the generator can render this type directly in a field or + /// element position. + /// + /// The other variants name something that must be generated as its own + /// item — a struct, an enum, a union — so a field can only hold them by + /// reference. Analysis hoists those and leaves a + /// [`SchemaType::Reference`]; anything that reaches the generator + /// un-hoisted is rendered as `serde_json::Value`, losing the type the + /// schema had. [`UntypedReason::inline_drop`] names those cases so the + /// census can count them. + pub fn renders_inline(&self) -> bool { + match self { + Self::Primitive { .. } + | Self::Reference { .. } + | Self::Array { .. } + | Self::Tuple { .. } + | Self::Untyped { .. } => true, + Self::Object { .. } + | Self::StringEnum { .. } + | Self::ExtensibleEnum { .. } + | Self::DiscriminatedUnion { .. } + | Self::Union { .. } + | Self::Composition { .. } => false, + } + } +} + +impl UntypedReason { + /// The reason a non-inline-renderable type reaching a field position gets + /// dropped to `serde_json::Value`. + pub fn inline_drop(schema_type: &SchemaType) -> Option { + match schema_type { + SchemaType::Composition { .. } => Some(Self::InlineCompositionDropped), + SchemaType::Union { .. } | SchemaType::DiscriminatedUnion { .. } => { + Some(Self::InlineUnionDropped) + } + SchemaType::Object { .. } => Some(Self::InlineObjectDropped), + SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => { + Some(Self::InlineEnumDropped) + } + _ => None, + } + } +} + /// Convert any `serde_json::Value` still carried as a stringly-typed /// `Primitive` into [`SchemaType::Untyped`]. /// @@ -201,9 +247,21 @@ fn collect_untyped( .. } => { for (property_name, property) in properties { + let property_context = format!("{context}.{property_name}"); + // A type the generator cannot render inline is dropped whole: + // count it here rather than descending into a type that will + // never reach the output. + if let Some(reason) = UntypedReason::inline_drop(&property.schema_type) { + findings.push(UntypedFinding { + context: property_context, + shape: UntypedShape::Value, + reason, + }); + continue; + } collect_untyped( &property.schema_type, - &format!("{context}.{property_name}"), + &property_context, findings, depth + 1, ); @@ -224,7 +282,16 @@ fn collect_untyped( } } SchemaType::Array { item_type } => { - collect_untyped(item_type, &format!("{context}[]"), findings, depth + 1) + let element_context = format!("{context}[]"); + if let Some(reason) = UntypedReason::inline_drop(item_type) { + findings.push(UntypedFinding { + context: element_context, + shape: UntypedShape::Value, + reason, + }); + } else { + collect_untyped(item_type, &element_context, findings, depth + 1); + } } SchemaType::Tuple { element_types } => { for (index, element_type) in element_types.iter().enumerate() { @@ -323,6 +390,17 @@ pub enum UntypedReason { UnsupportedTypeKeyword, /// A `$ref` that analysis could not resolve to a generated schema. UnresolvedReference, + /// An `allOf` composition sitting in a field position. Analysis did not + /// merge or hoist it, and a field cannot hold one, so the generator emits + /// `serde_json::Value` — dropping a type the schema fully described. A + /// single-branch `allOf` around a scalar is the common shape. + InlineCompositionDropped, + /// A union in a field position that was never hoisted to a named enum. + InlineUnionDropped, + /// An inline object in a field position that was never hoisted to a struct. + InlineObjectDropped, + /// An inline enum in a field position that was never hoisted. + InlineEnumDropped, /// A `oneOf`/`anyOf` branch that mapped to an untyped value, so the /// generated union carries a `serde_json::Value` variant. Whether that is /// faithful depends on the branch, which the analyzed type no longer says. @@ -345,6 +423,12 @@ impl UntypedReason { // positional items permit extras of any type: both are the spec's // choice, not a dropped constraint. Self::ArrayWithoutItems | Self::OpenPositionalItems => UntypedVerdict::Faithful, + // The schema described a type that the generator then dropped + // because nothing hoisted it out of the field position. + Self::InlineCompositionDropped + | Self::InlineUnionDropped + | Self::InlineObjectDropped + | Self::InlineEnumDropped => UntypedVerdict::Recoverable, // These carried type information that did not survive analysis. Self::UnrepresentableUnion | Self::UnrepresentableComposition @@ -1351,6 +1435,9 @@ pub struct SchemaAnalyzer { /// config; threaded from `GeneratorConfig.types` via /// [`Self::with_type_mapper`]. type_mapper: TypeMapper, + /// Pointer targets currently being expanded, so a node that references + /// itself through a pointer stops at a reference instead of recursing. + resolving_pointers: HashSet, } impl SchemaAnalyzer { @@ -1421,6 +1508,7 @@ impl SchemaAnalyzer { current_schema_name: None, component_parameters, type_mapper, + resolving_pointers: HashSet::new(), }) } @@ -2027,10 +2115,10 @@ impl SchemaAnalyzer { let schema_type = match schema { Schema::Reference { reference, .. } => { - // For real-world refs we can't resolve to a known schema name - // (e.g. pagerduty's `#/components/parameters/foo/schema`), - // fall back to opaque JSON instead of failing whole-document - // generation. The rest of the spec is usually unaffected. + // A ref that names no component schema may still address a node + // in this document — a parameter's schema, a response body, one + // member of a composition. Resolve the pointer before giving up + // and typing the field as opaque JSON. match self.extract_schema_name(reference) { Some(name) => { let target = name.to_string(); @@ -2038,14 +2126,21 @@ impl SchemaAnalyzer { SchemaType::Reference { target } } None => { - eprintln!( - "⚠️ unresolvable $ref `{}` — typing as serde_json::Value", - reference - ); - self.untyped_value( - format!("$ref {reference}"), - UntypedReason::UnresolvedReference, - ) + let reference = reference.clone(); + if let Some(resolved) = + self.resolve_pointer_schema(&reference, &mut dependencies)? + { + resolved + } else { + eprintln!( + "⚠️ unresolvable $ref `{}` — typing as serde_json::Value", + reference + ); + self.untyped_value( + format!("$ref {reference}"), + UntypedReason::UnresolvedReference, + ) + } } } } @@ -2099,6 +2194,17 @@ impl SchemaAnalyzer { discriminator, .. } => { + if Self::union_only_constrains_requiredness(any_of) { + return Ok(AnalyzedSchema { + name: schema_name.to_string(), + original: serde_json::to_value(schema).unwrap_or(Value::Null), + schema_type: self.analyze_empty_union(schema, &mut dependencies)?, + dependencies, + nullable, + description, + default: details.default.clone(), + }); + } // Handle anyOf patterns (nullable vs flexible union vs discriminated) self.analyze_anyof_union( any_of, @@ -2112,13 +2218,17 @@ impl SchemaAnalyzer { discriminator, .. } => { - // Handle oneOf discriminated unions - self.analyze_oneof_union( - one_of, - discriminator.as_ref(), - schema_name, - &mut dependencies, - )? + if one_of.is_empty() { + self.analyze_empty_union(schema, &mut dependencies)? + } else { + // Handle oneOf discriminated unions + self.analyze_oneof_union( + one_of, + discriminator.as_ref(), + schema_name, + &mut dependencies, + )? + } } Schema::AllOf { all_of, .. } => { // Handle allOf composition (schema inheritance) @@ -2143,6 +2253,12 @@ impl SchemaAnalyzer { values: details.string_enum_values().unwrap_or_default(), } } + // `type: null` admits exactly one value; Rust spells + // that `()`, which serde reads from and writes as null. + OpenApiSchemaType::Null => SchemaType::Primitive { + rust_type: self.type_mapper.null_unit().rust_type, + serde_with: None, + }, _ => self.untyped_value( self.untyped_context(""), UntypedReason::UnsupportedTypeKeyword, @@ -2215,10 +2331,12 @@ impl SchemaAnalyzer { self.analyze_object_schema(schema, dependencies)? } } - _ => self.untyped_value( - self.untyped_context(""), - UntypedReason::UnsupportedTypeKeyword, - ), + // `type: null` admits exactly one value. Rust spells that `()`, + // which serde reads from and writes as `null`. + OpenApiSchemaType::Null => SchemaType::Primitive { + rust_type: self.type_mapper.null_unit().rust_type, + serde_with: None, + }, }) } @@ -2236,6 +2354,12 @@ impl SchemaAnalyzer { .unwrap_or_default(); let mut property_info = BTreeMap::new(); + // Names hoisted property types after the schema being analyzed, which + // is what `{Parent}{Property}` reads as in generated code. + let owner_name = self + .current_schema_name + .clone() + .unwrap_or_else(|| "Inline".to_string()); if let Some(props) = properties { for (prop_name, prop_schema) in props { @@ -2344,6 +2468,16 @@ impl SchemaAnalyzer { Some(prop_name), dependencies, )?; + let owner_name = self + .current_schema_name + .clone() + .unwrap_or_else(|| "Inline".to_string()); + let unwrapped = self.hoist_inline_property_type( + &owner_name, + prop_name, + unwrapped, + dependencies, + ); let prop_details = prop_schema.details(); let prop_nullable = true; let prop_description = prop_details.description.clone(); @@ -2425,6 +2559,13 @@ impl SchemaAnalyzer { )? }; + let prop_type = self.hoist_inline_property_type( + &owner_name, + prop_name, + prop_type, + dependencies, + ); + let prop_details = prop_schema.details(); // Every nullability form, via one helper — see is_nullable_any. let prop_nullable = prop_schema.is_nullable_any(); @@ -2579,6 +2720,13 @@ impl SchemaAnalyzer { return Ok(SchemaType::Reference { target }); } None => { + // Not a component schema, but possibly still a local + // pointer into one: specs reference a parameter's schema + // (`#/components/parameters/x/schema`) or a member of a + // composition (`#/components/schemas/Tag/allOf/0`). + if let Some(resolved) = self.resolve_pointer_schema(ref_str, dependencies)? { + return Ok(resolved); + } eprintln!( "⚠️ unresolvable $ref `{}` — typing as serde_json::Value", ref_str @@ -2779,11 +2927,13 @@ impl SchemaAnalyzer { target: object_type_name, }); } - _ => { - return Ok(self.untyped_value( - self.untyped_context(""), - UntypedReason::UnsupportedTypeKeyword, - )); + // `type: null` admits exactly one value; Rust spells that + // `()`, which serde reads from and writes as null. + OpenApiSchemaType::Null => { + return Ok(SchemaType::Primitive { + rust_type: self.type_mapper.null_unit().rust_type, + serde_with: None, + }); } } } @@ -3012,6 +3162,19 @@ impl SchemaAnalyzer { }); } + // A single member composes with nothing: `allOf: [{type: string}]` is + // that string. Specs write it to hang a description off a scalar or a + // `$ref`, and merging it as an object would lose the type entirely. + if let [only] = all_of_schemas + && !matches!( + only.schema_type(), + Some(OpenApiSchemaType::Object) | Some(OpenApiSchemaType::Null) + ) + && only.details().properties.is_none() + { + return self.analyze_property_schema_with_context(only, None, dependencies); + } + // AllOf represents schema composition - merge all schemas into one let mut merged_properties = BTreeMap::new(); let mut merged_required = HashSet::new(); @@ -3147,6 +3310,16 @@ impl SchemaAnalyzer { Some(prop_name), dependencies, )?; + let owner_name = self + .current_schema_name + .clone() + .unwrap_or_else(|| "Inline".to_string()); + let prop_type = self.hoist_inline_property_type( + &owner_name, + prop_name, + prop_type, + dependencies, + ); let prop_details = prop_schema.details(); // Properties merged through allOf composition must go through @@ -3187,6 +3360,29 @@ impl SchemaAnalyzer { parent_name: &str, dependencies: &mut HashSet, ) -> Result { + // Branches may be pointers into other parts of the document. + let expanded_branches; + let one_of_schemas = match self.expand_pointer_branches(one_of_schemas) { + Some(expanded) => { + expanded_branches = expanded; + expanded_branches.as_slice() + } + None => one_of_schemas, + }; + + // A union of one is that one, and branches that differ only in + // constraints share one Rust type. Both are checked before the shape + // patterns below, which would otherwise synthesize a union type and + // then fail to represent it. + if let [only] = one_of_schemas { + return self + .analyze_schema_value(only, parent_name) + .map(|analyzed| analyzed.schema_type); + } + if let Some(shared) = self.shared_branch_type(one_of_schemas) { + return Ok(shared); + } + // Pattern: nullable [Type, null] — return the non-null type directly. // The nullable bit is recorded at the property level via is_nullable_pattern(). if one_of_schemas.len() == 2 { @@ -4286,6 +4482,12 @@ impl SchemaAnalyzer { &format!("{parent_schema_name}Item"), dependencies, )?; + let item_type = self.hoist_inline_property_type( + parent_schema_name, + "Item", + item_type, + dependencies, + ); Ok(SchemaType::Array { item_type: Box::new(item_type), }) @@ -4300,6 +4502,294 @@ impl SchemaAnalyzer { } } + /// The single Rust type every branch of a union maps to, if there is one. + /// + /// Specs routinely spell one type as several branches that differ only in + /// constraints — Runway declares a URI field as three `string` branches + /// with different `pattern`s and lengths. Every value that matches any + /// branch is still a `String`, so the union has an exact Rust type; only + /// the constraints, which are documentation here, differ. Branches whose + /// mapped types disagree (a `uri` alongside a plain string) are left alone. + fn shared_branch_type(&self, branches: &[Schema]) -> Option { + let mut mapped: Option<(String, Option)> = None; + let mut scalar_kind: Option = None; + let mut formats_agree = true; + for branch in branches { + if branch.reference().is_some() { + return None; + } + let details = branch.details(); + if details.enum_values.is_some() + || details.const_value.is_some() + || details.properties.is_some() + { + return None; + } + let scalar = match branch.schema_type()? { + scalar @ (OpenApiSchemaType::String + | OpenApiSchemaType::Integer + | OpenApiSchemaType::Number + | OpenApiSchemaType::Boolean) => scalar.clone(), + _ => return None, + }; + match &scalar_kind { + Some(existing) if *existing != scalar => return None, + Some(_) => {} + None => scalar_kind = Some(scalar.clone()), + } + + let candidate = self.type_mapper.map(scalar, details); + let candidate = (candidate.rust_type, candidate.serde_with); + match &mapped { + Some(existing) if *existing != candidate => formats_agree = false, + Some(_) => {} + None => mapped = Some(candidate), + } + } + + if formats_agree { + return mapped.map(|(rust_type, serde_with)| SchemaType::Primitive { + rust_type, + serde_with, + }); + } + + // Same wire type, different typed-scalar refinements — gcore declares an + // IP field as `ipv4 | ipv6 | ipv4network | ipv6network`, which map to + // three different Rust types. No single refinement holds for every + // value, but the declared type does, so fall back to it rather than to + // `serde_json::Value`. + let scalar = scalar_kind?; + let mapped = self + .type_mapper + .map(scalar, &crate::openapi::SchemaDetails::default()); + Some(SchemaType::Primitive { + rust_type: mapped.rust_type, + serde_with: mapped.serde_with, + }) + } + + /// Replace union branches that are local JSON Pointers with the schemas + /// they name. + /// + /// PagerDuty builds a request body from three pointers into a response's + /// `oneOf`. Each branch is resolvable, but a union whose branches are + /// unresolvable references has nothing to build variants from, so the whole + /// union used to degrade to `serde_json::Value`. Expanding is one level + /// deep, which is all these shapes need. + fn expand_pointer_branches(&self, branches: &[Schema]) -> Option> { + let mut expanded = Vec::with_capacity(branches.len()); + let mut changed = false; + for branch in branches { + let resolved = branch + .reference() + .filter(|reference| self.extract_schema_name(reference).is_none()) + .and_then(|reference| reference.strip_prefix('#')) + .filter(|pointer| pointer.starts_with('/')) + .and_then(|pointer| self.openapi_spec.pointer(pointer)) + .and_then(|value| Schema::deserialize(value).ok()) + .filter(|schema| schema.reference().is_none()); + match resolved { + Some(schema) => { + expanded.push(schema); + changed = true; + } + None => expanded.push(branch.clone()), + } + } + changed.then_some(expanded) + } + + /// Whether a union's branches constrain only which properties are + /// required. + /// + /// Cloudflare writes `{properties: {...}, anyOf: [{required: [commit_hash]}, + /// {required: [branch]}]}` to say "one of these two fields must be present". + /// The branches carry no type of their own, and Rust has no way to express + /// the alternation, so the schema is the object its properties describe — + /// with both fields optional — rather than an unrepresentable union. + fn union_only_constrains_requiredness(branches: &[Schema]) -> bool { + !branches.is_empty() + && branches.iter().all(|branch| { + let details = branch.details(); + branch.schema_type().is_none() + && branch.reference().is_none() + && branch.union_variants().is_none() + && details.properties.is_none() + && details.enum_values.is_none() + && details.const_value.is_none() + && details.items.is_none() + && details.additional_properties.is_none() + && details.required.is_some() + }) + } + + /// Analyze a union whose branch list is empty. + /// + /// `oneOf: []` and `anyOf: []` admit every value, so the schema means + /// whatever its remaining keywords say. Discord ships + /// `{type: integer, format: int32, oneOf: []}` for several enums; reading + /// only the empty union throws away a perfectly good `i32`. + fn analyze_empty_union( + &mut self, + schema: &Schema, + dependencies: &mut HashSet, + ) -> Result { + // A union that declares no type of its own is still an object when it + // carries properties: the branches sit alongside them, not instead of + // them. + let Some(declared) = schema + .declared_type() + .cloned() + .or_else(|| schema.inferred_type()) + .or_else(|| { + schema + .details() + .properties + .is_some() + .then_some(OpenApiSchemaType::Object) + }) + else { + return Ok(self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema)); + }; + match declared { + OpenApiSchemaType::Object => self.analyze_object_schema(schema, dependencies), + OpenApiSchemaType::Array => { + let context = self + .current_schema_name + .clone() + .unwrap_or_else(|| "Inline".to_string()); + self.analyze_array_schema(schema, &context, dependencies) + } + scalar => { + let mapped = self.type_mapper.map(scalar, schema.details()); + Ok(SchemaType::Primitive { + rust_type: mapped.rust_type, + serde_with: mapped.serde_with, + }) + } + } + } + + /// Resolve a local `$ref` that points somewhere other than + /// `#/components/schemas/`. + /// + /// A JSON Pointer may address any node in the document, and real specs use + /// that: PagerDuty references a parameter's schema + /// (`#/components/parameters/audit_method_type/schema`) and a single member + /// of another schema's composition (`#/components/schemas/Tag/allOf/0`). + /// Resolving only the component-schema form left those fields untyped even + /// though the target is right there in the document. + /// + /// The target is analyzed as an inline schema and named after its pointer, + /// so two references to the same node share one generated type. + fn resolve_pointer_schema( + &mut self, + reference: &str, + dependencies: &mut HashSet, + ) -> Result> { + let Some(pointer) = reference.strip_prefix('#') else { + return Ok(None); + }; + if pointer.is_empty() || !pointer.starts_with('/') { + return Ok(None); + } + let name = pointer_type_name(pointer); + if name.is_empty() { + return Ok(None); + } + // Already resolved once, or currently being resolved further up the + // stack: reference the name rather than expanding it again. + if self.resolved_cache.contains_key(&name) || !self.resolving_pointers.insert(name.clone()) + { + dependencies.insert(name.clone()); + return Ok(Some(SchemaType::Reference { target: name })); + } + + let resolved = (|| { + let value = self.openapi_spec.pointer(pointer)?.clone(); + Schema::deserialize(&value).ok() + })(); + let Some(schema) = resolved else { + self.resolving_pointers.remove(&name); + return Ok(None); + }; + + let analyzed = self.analyze_property_schema_with_context(&schema, None, dependencies); + self.resolving_pointers.remove(&name); + let analyzed = analyzed?; + Ok(Some(self.hoist_inline_property_type( + &name, + "", + analyzed, + dependencies, + ))) + } + + /// Give a property type a name when it needs one. + /// + /// A struct field can hold a primitive, a reference, an array, or a tuple. + /// Anything else — a merged `allOf`, an inline object, a union, an inline + /// enum — has to be generated as its own item, and a field can only reach + /// it by reference. Analysis used to leave those in place, and the + /// generator, with nothing it could write, emitted `serde_json::Value`: + /// the schema was understood and then thrown away at the last step. + fn hoist_inline_property_type( + &mut self, + schema_name: &str, + property_name: &str, + schema_type: SchemaType, + dependencies: &mut HashSet, + ) -> SchemaType { + if schema_type.renders_inline() { + return schema_type; + } + + let description = match &schema_type { + SchemaType::Object { .. } => None, + _ => None, + }; + let hoisted_name = self.unique_hoisted_name(schema_name, property_name); + self.resolved_cache.insert( + hoisted_name.clone(), + AnalyzedSchema { + name: hoisted_name.clone(), + original: Value::Null, + schema_type, + dependencies: dependencies.clone(), + nullable: false, + description, + default: None, + }, + ); + dependencies.insert(hoisted_name.clone()); + SchemaType::Reference { + target: hoisted_name, + } + } + + /// A generated name for a hoisted property type that no other schema has + /// claimed. Collisions are resolved by suffix rather than by overwriting, + /// which would silently retype an unrelated schema. + fn unique_hoisted_name(&self, schema_name: &str, property_name: &str) -> String { + use heck::ToPascalCase; + + let base = format!("{schema_name}{}", property_name.to_pascal_case()); + if !self.schemas.contains_key(&base) && !self.resolved_cache.contains_key(&base) { + return base; + } + let mut suffix = 2; + loop { + let candidate = format!("{base}{suffix}"); + if !self.schemas.contains_key(&candidate) + && !self.resolved_cache.contains_key(&candidate) + { + return candidate; + } + suffix += 1; + } + } + /// Analyze positional element schemas — 2020-12 `prefixItems` or the /// draft-04 `items: [A, B]` tuple form — into the tightest type the spec /// justifies. @@ -4322,12 +4812,18 @@ impl SchemaAnalyzer { if details.positional_items_are_exact() && !positions.is_empty() { let mut element_types = Vec::with_capacity(positions.len()); for (index, position) in positions.iter().enumerate() { - element_types.push(self.analyze_item_schema( + let element_type = self.analyze_item_schema( position, parent_schema_name, &format!("{parent_schema_name}Item{}", index + 1), dependencies, - )?); + )?; + element_types.push(self.hoist_inline_property_type( + parent_schema_name, + &format!("Item{}", index + 1), + element_type, + dependencies, + )); } return Ok(SchemaType::Tuple { element_types }); } @@ -4559,6 +5055,12 @@ impl SchemaAnalyzer { rust_type: "bool".to_string(), serde_with: None, }, + // `type: null` admits exactly one value; Rust spells + // that `()`, which serde reads from and writes as null. + OpenApiSchemaType::Null => SchemaType::Primitive { + rust_type: self.type_mapper.null_unit().rust_type, + serde_with: None, + }, _ => self.untyped_value( self.untyped_context(""), UntypedReason::UnsupportedTypeKeyword, @@ -4568,10 +5070,11 @@ impl SchemaAnalyzer { self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema) } } - _ => self.untyped_value( - self.untyped_context(""), - UntypedReason::UnrepresentableComposition, - ), + // Compositions and anything else this match does not special-case + // go through the general property analyzer, which understands + // `allOf` merging. The caller hoists whatever comes back if a field + // cannot hold it directly. + _ => self.analyze_property_schema_with_context(items_schema, None, dependencies)?, }; Ok(item_type) @@ -4600,6 +5103,16 @@ impl SchemaAnalyzer { dependencies: &mut HashSet, context_name: &str, ) -> Result { + // Branches may be pointers into other parts of the document. + let expanded_branches; + let any_of_schemas = match self.expand_pointer_branches(any_of_schemas) { + Some(expanded) => { + expanded_branches = expanded; + expanded_branches.as_slice() + } + None => any_of_schemas, + }; + // Drop {"type": "null"} variants. Nullability is surfaced as Option // at the property level via is_nullable_pattern(); leaving the null // variant in here would produce a phantom `()` or `serde_json::Value` @@ -4627,6 +5140,21 @@ impl SchemaAnalyzer { any_of_schemas }; + // A union of one is that one: gcore writes `anyOf: [{allOf: [...]}]` + // to attach an example to a referenced error schema. + if let [only] = any_of_schemas { + return self + .analyze_schema_value(only, context_name) + .map(|analyzed| analyzed.schema_type); + } + + // Branches that differ only in constraints share one Rust type, so + // there is no union to build. Checked before the shape patterns below, + // which would otherwise synthesize a union type and give up on it. + if let Some(shared) = self.shared_branch_type(any_of_schemas) { + return Ok(shared); + } + // Pattern 2: Multiple complex types or mixed primitive/complex = flexible union let has_refs = any_of_schemas.iter().any(|s| s.is_reference()); let has_objects = any_of_schemas.iter().any(|s| { @@ -4856,12 +5384,27 @@ impl SchemaAnalyzer { let mut has_open_string = false; for schema in any_of_schemas { - if let Some(const_val) = &schema.details().const_value { - if let Some(const_str) = const_val.as_str() { - enum_values.push(const_str.to_string()); + // A branch may enumerate its values (`enum: [...]`), pin one + // (`const`), or accept any string. The last is what makes the + // union extensible rather than closed: "one of these, or + // anything else" is exactly `ExtensibleEnum`. + match schema + .details() + .string_enum_values() + .filter(|values| !values.is_empty()) + { + Some(values) => { + for value in values { + if !enum_values.contains(&value) { + enum_values.push(value); + } + } + } + None => { + if matches!(schema.schema_type(), Some(OpenApiSchemaType::String)) { + has_open_string = true; + } } - } else if matches!(schema.schema_type(), Some(OpenApiSchemaType::String)) { - has_open_string = true; } } @@ -6407,6 +6950,25 @@ impl SchemaAnalyzer { /// untagged `Schema` enum that is "data did not match any variant of untagged /// enum Schema", with no field, schema name, or position. Tracking the path /// turns that into a JSON Pointer the author can jump straight to (issue #60). +/// A Rust type name for a JSON Pointer target, e.g. +/// `/components/schemas/Tag/allOf/0` becomes `TagAllOf0`. The `components` +/// section prefix carries no information and is dropped. +fn pointer_type_name(pointer: &str) -> String { + use heck::ToPascalCase; + + pointer + .split('/') + .skip(1) + .filter(|segment| !matches!(*segment, "components" | "schemas" | "properties")) + .map(|segment| { + segment + .replace("~1", "/") + .replace("~0", "~") + .to_pascal_case() + }) + .collect::() +} + /// The one schema every position shares, if they are interchangeable: the same /// `$ref`, or the same primitive type and format. Inline objects never qualify /// — two structurally identical inline objects still hoist two named types, so diff --git a/src/openapi.rs b/src/openapi.rs index 5c8ab66..0b5dea3 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -136,6 +136,11 @@ pub enum Schema { }, /// OneOf union OneOf { + /// Some specs declare the branch type alongside the union, the way + /// `anyOf` does — Discord ships `{type: integer, oneOf: []}`. Modeling + /// it keeps that type reachable instead of leaving it in `extra`. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + schema_type: Option, #[serde(rename = "oneOf")] one_of: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -700,6 +705,21 @@ impl Schema { } } + /// The `type` keyword as written, including on a union or composition. + /// + /// [`Self::schema_type`] deliberately reports only standalone typed + /// schemas, because most callers ask it in order to pick a Rust type for a + /// leaf. A union that also declares `type` needs the declared value — + /// notably when the branch list is empty and the union constrains nothing. + pub fn declared_type(&self) -> Option<&SchemaType> { + match self { + Schema::AnyOf { schema_type, .. } + | Schema::AllOf { schema_type, .. } + | Schema::OneOf { schema_type, .. } => schema_type.as_ref(), + other => other.schema_type(), + } + } + /// 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 @@ -840,9 +860,8 @@ impl Schema { _ => return false, }; variants.len() == 2 - && variants - .iter() - .any(|s| matches!(s.schema_type(), Some(SchemaType::Null))) + && variants.iter().any(Schema::is_null_marker) + && variants.iter().any(|s| !s.is_null_marker()) } /// Get the non-null variant from a nullable pattern @@ -855,9 +874,53 @@ impl Schema { Schema::OneOf { one_of, .. } => one_of, _ => return None, }; - variants - .iter() - .find(|s| !matches!(s.schema_type(), Some(SchemaType::Null))) + variants.iter().find(|s| !s.is_null_marker()) + } + + /// Whether this branch of a union exists only to admit `null`. + /// + /// 3.1 spells that `type: "null"`. Tooling that predates it spells it as an + /// empty object carrying `nullable: true` — OData emits + /// `anyOf: [$ref, {type: object, nullable: true}]` for every navigation + /// property, which is "that type, or null", not "that type or any object". + /// Reading the second branch literally costs the first branch its type: + /// the union has no single Rust representation and the field degrades to + /// `serde_json::Value`. + /// + /// Only an *empty* nullable object qualifies. A nullable branch that + /// constrains anything — properties, `additionalProperties`, a `$ref`, an + /// enum — is a real alternative and is left alone. + pub fn is_null_marker(&self) -> bool { + if matches!(self.schema_type(), Some(SchemaType::Null)) { + return true; + } + let details = self.details(); + if !details.is_nullable() { + return false; + } + if !matches!( + self.schema_type(), + Some(SchemaType::Object) | Some(SchemaType::Null) | None + ) { + return false; + } + self.reference().is_none() + && details.properties.as_ref().is_none_or(BTreeMap::is_empty) + && details.additional_properties.is_none() + && details.enum_values.is_none() + && details.const_value.is_none() + && details.pattern_properties.is_none() + && details.items.is_none() + && details.prefix_items.is_none() + && self.all_of_len() == 0 + } + + /// Number of `allOf` members, for shapes that only matter when empty. + fn all_of_len(&self) -> usize { + match self { + Schema::AllOf { all_of, .. } => all_of.len(), + _ => 0, + } } /// Infer schema type from structure if not explicitly set diff --git a/tests/recoverable_typing_test.rs b/tests/recoverable_typing_test.rs new file mode 100644 index 0000000..0225a4f --- /dev/null +++ b/tests/recoverable_typing_test.rs @@ -0,0 +1,395 @@ +//! Regression tests for schemas that used to generate `serde_json::Value` +//! despite carrying enough information to type (issue #62 follow-up). +//! +//! Each case here was found by the untyped census (`--report-untyped`) across +//! the 57-spec corpus and traced back to a real document. The tests assert two +//! things per pattern: the generated Rust type, and that the census no longer +//! reports the field as recoverable — a fix that types the field but leaves the +//! census claiming otherwise would make the corpus numbers lie. +//! +//! The negative cases matter as much as the positive ones. Several fixes narrow +//! a schema to a type the spec does not strictly require, and the tests pin the +//! line: a union that genuinely has no single Rust type must stay a union, and +//! an unconstrained value must stay `serde_json::Value`. + +use openapi_to_rust::analysis::{SchemaAnalysis, UntypedVerdict}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; + +fn analyze(spec: Value) -> SchemaAnalysis { + SchemaAnalyzer::new(spec) + .expect("spec parses") + .analyze() + .expect("spec analyzes") +} + +fn generate(spec: Value) -> String { + let mut analysis = analyze(spec.clone()); + CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("code generates") +} + +/// Generated source plus the census verdict, which every case checks together. +fn generate_and_census(spec: Value) -> (String, Vec) { + let generated = generate(spec.clone()); + let recoverable = analyze(spec) + .untyped_fields() + .into_iter() + .filter(|finding| finding.reason.verdict() == UntypedVerdict::Recoverable) + .map(|finding| format!("{} ({:?})", finding.context, finding.reason)) + .collect(); + (generated, recoverable) +} + +fn spec_with_schemas(schemas: Value) -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "typing", "version": "1.0.0" }, + "components": { "schemas": schemas } + }) +} + +fn assert_types(spec: Value, expected: &[&str]) { + let (generated, recoverable) = generate_and_census(spec); + for want in expected { + assert!( + generated.contains(want), + "expected `{want}` in generated output:\n{generated}" + ); + } + assert!( + recoverable.is_empty(), + "census still reports recoverable untyped fields: {recoverable:?}" + ); +} + +#[test] +fn odata_nullable_reference_union_becomes_option() { + // `anyOf: [$ref, {type: object, nullable: true}]` is how OData spells "that + // type, or null" — Microsoft Graph emits it for every navigation property, + // 2,127 times across the corpus. Read literally it is "that type or any + // object", which has no single Rust type. + assert_types( + spec_with_schemas(json!({ + "User": { "type": "object", "additionalProperties": false, + "properties": { "id": { "type": "string" } } }, + "Member": { "type": "object", "additionalProperties": false, "properties": { + "user": { "anyOf": [ + { "$ref": "#/components/schemas/User" }, + { "type": "object", "nullable": true } + ]} + }} + })), + &["pub user: Option"], + ); +} + +#[test] +fn a_nullable_branch_that_constrains_something_stays_a_union() { + // The narrowing above applies only to an *empty* nullable object. A branch + // with properties of its own is a real alternative, and collapsing it would + // silently drop a shape the API can return. + let (generated, _) = generate_and_census(spec_with_schemas(json!({ + "User": { "type": "object", "additionalProperties": false, + "properties": { "id": { "type": "string" } } }, + "Member": { "type": "object", "additionalProperties": false, "properties": { + "user": { "anyOf": [ + { "$ref": "#/components/schemas/User" }, + { "type": "object", "nullable": true, + "properties": { "deletedAt": { "type": "string" } } } + ]} + }} + }))); + + assert!( + !generated.contains("pub user: Option"), + "a branch with its own properties must not collapse to the other branch:\n{generated}" + ); +} + +#[test] +fn open_string_enum_union_becomes_an_extensible_enum() { + // Anthropic's `AnthropicBeta`: a named set of values, plus any other string. + assert_types( + spec_with_schemas(json!({ + "Beta": { "anyOf": [ + { "type": "string" }, + { "type": "string", "enum": ["computer-use-2024-10-22", "pdfs-2024-09-25"] } + ]}, + "Holder": { "type": "object", "additionalProperties": false, + "properties": { "beta": { "$ref": "#/components/schemas/Beta" } } } + })), + &["pub enum Beta", "ComputerUse20241022", "Custom(String)"], + ); +} + +#[test] +fn single_member_composition_keeps_its_member_type() { + // `allOf: [{type: string}]` is how countless 3.0 specs hang a description + // off a scalar. Box does it for `sequence_id`. + assert_types( + spec_with_schemas(json!({ + "Thing": { "type": "object", "additionalProperties": false, "properties": { + "sequence_id": { "allOf": [{ "type": "string", "description": "a numeric id" }] } + }} + })), + &["pub sequence_id: Option"], + ); +} + +#[test] +fn composition_of_a_reference_and_an_extension_is_hoisted_to_a_struct() { + // Asana's `AllocationResponse.assignee`: a `$ref` merged with an inline + // object that adds fields. The merge already worked; the merged object then + // sat in a field position, which the generator cannot render. + let (generated, recoverable) = generate_and_census(spec_with_schemas(json!({ + "UserCompact": { "type": "object", "additionalProperties": false, + "properties": { "gid": { "type": "string" } } }, + "Allocation": { "type": "object", "additionalProperties": false, "properties": { + "assignee": { "allOf": [ + { "$ref": "#/components/schemas/UserCompact" }, + { "type": "object", "properties": { "name": { "type": "string" } } } + ]} + }} + }))); + + assert!( + !generated.contains("pub assignee: Option"), + "the merged composition must keep a type:\n{generated}" + ); + assert!( + generated.contains("pub gid") && generated.contains("pub name"), + "the hoisted type must carry both sides of the merge:\n{generated}" + ); + assert!(recoverable.is_empty(), "{recoverable:?}"); +} + +#[test] +fn an_inline_object_property_is_hoisted_to_a_named_struct() { + assert_types( + spec_with_schemas(json!({ + "Thing": { "type": "object", "additionalProperties": false, "properties": { + "nested": { "type": "object", "additionalProperties": false, + "properties": { "count": { "type": "integer" } } } + }} + })), + &["pub nested: Option", "pub struct ThingNested"], + ); +} + +#[test] +fn a_composition_inside_array_items_keeps_its_type() { + // Asana's `WebhookRequest.filters[]`. Array elements went through an + // analyzer that had no `allOf` case at all. + assert_types( + spec_with_schemas(json!({ + "Filter": { "type": "object", "additionalProperties": false, + "properties": { "action": { "type": "string" } } }, + "Webhook": { "type": "object", "additionalProperties": false, "properties": { + "filters": { "type": "array", "items": { "allOf": [ + { "$ref": "#/components/schemas/Filter" }, + { "type": "object", "properties": { "fields": { "type": "string" } } } + ]}} + }} + })), + &["pub filters: Option` used to resolve. + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "typing", "version": "1.0.0" }, + "components": { + "parameters": { + "audit_method_type": { + "name": "type", "in": "query", + "schema": { "type": "string", "enum": ["web_session", "api_token"] } + } + }, + "schemas": { + "Method": { "type": "object", "additionalProperties": false, "properties": { + "type": { "$ref": "#/components/parameters/audit_method_type/schema" } + }} + } + } + }); + + let (generated, recoverable) = generate_and_census(spec); + assert!( + !generated.contains("pub r#type: Option") + && !generated.contains("pub type_: Option"), + "a resolvable pointer must not degrade to an untyped value:\n{generated}" + ); + assert!(recoverable.is_empty(), "{recoverable:?}"); +} + +#[test] +fn a_null_typed_property_becomes_the_unit_type() { + // Discord's `GuildRoleTagsResponse.premium_subscriber` is `{"type": "null"}`: + // present, always null. serde reads and writes `()` as exactly that. + assert_types( + spec_with_schemas(json!({ + "Tags": { "type": "object", "additionalProperties": false, "properties": { + "premium_subscriber": { "type": "null" } + }} + })), + &["pub premium_subscriber: Option<()>"], + ); +} + +#[test] +fn an_empty_union_falls_back_to_the_declared_type() { + // Discord ships `{type: integer, format: int32, oneOf: []}`. An empty branch + // list constrains nothing, so the `type` keyword is the whole schema. + assert_types( + spec_with_schemas(json!({ + "OwnerTypes": { "type": "integer", "format": "int32", "oneOf": [] }, + "Palette": { "type": "string", "oneOf": [] }, + "Holder": { "type": "object", "additionalProperties": false, "properties": { + "owner": { "$ref": "#/components/schemas/OwnerTypes" }, + "palette": { "$ref": "#/components/schemas/Palette" } + }} + })), + &["pub type OwnerTypes = i32", "pub type Palette = String"], + ); +} + +#[test] +fn a_single_branch_union_is_that_branch() { + // gcore wraps a referenced error schema in `anyOf: [...]` to attach an + // example to it. + assert_types( + spec_with_schemas(json!({ + "ValidationError": { "type": "object", "additionalProperties": false, + "properties": { "detail": { "type": "string" } } }, + "ChangePasswordError": { "anyOf": [ + { "allOf": [{ "$ref": "#/components/schemas/ValidationError" }] } + ]}, + "Holder": { "type": "object", "additionalProperties": false, "properties": { + "error": { "$ref": "#/components/schemas/ChangePasswordError" } + }} + })), + &["pub error: Option"], + ); +} + +#[test] +fn union_branches_differing_only_in_constraints_share_one_type() { + // Runway declares a URI field as three `string` branches with different + // `pattern`s and lengths. Every value is still a String. + assert_types( + spec_with_schemas(json!({ + "Media": { "type": "object", "additionalProperties": false, "properties": { + "uri": { "anyOf": [ + { "type": "string", "pattern": "^https://.*", "maxLength": 2048 }, + { "type": "string", "pattern": "^runway://.*", "maxLength": 5000 }, + { "type": "string", "pattern": "^data:.*" } + ]} + }} + })), + // Hoisted under a name, which resolves to the shared type. + &["pub uri: Option", "pub type MediaUri = String"], + ); +} + +#[test] +fn union_branches_with_different_formats_fall_back_to_the_wire_type() { + // gcore: `ipv4 | ipv6 | ipv4network | ipv6network`. The typed-scalar + // refinements disagree — `Ipv4Addr` is not `Ipv6Addr` — but every branch is + // a string, and that much holds for every value. + assert_types( + spec_with_schemas(json!({ + "Profile": { "type": "object", "additionalProperties": false, "properties": { + "ip_address": { "anyOf": [ + { "type": "string", "format": "ipv4" }, + { "type": "string", "format": "ipv6" }, + { "type": "null" } + ]} + }} + })), + &["String"], + ); +} + +#[test] +fn a_union_that_only_alternates_requiredness_is_the_object_it_describes() { + // Cloudflare: "one of commit_hash or branch must be present". Rust cannot + // express the alternation, but the object is right there in `properties`. + assert_types( + spec_with_schemas(json!({ + "Build": { + "anyOf": [{ "required": ["commit_hash"] }, { "required": ["branch"] }], + "properties": { + "branch": { "type": "string" }, + "commit_hash": { "type": "string" } + } + } + })), + &[ + "pub struct Build", + "pub branch: Option", + "pub commit_hash: Option", + ], + ); +} + +#[test] +fn union_branches_that_are_deep_pointers_are_expanded() { + // PagerDuty builds a request body from three pointers into a response's + // `oneOf`. Every branch resolves, but a union of unresolvable references + // had nothing to build variants from. + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "typing", "version": "1.0.0" }, + "components": { + "responses": { "CacheData": { + "description": "cache data", + "content": { "application/json": { "schema": { + "oneOf": [{ "type": "string" }, { "type": "number" }] + }}} + }}, + "schemas": { + "PutRequest": { "type": "object", "oneOf": [ + { "$ref": "#/components/responses/CacheData/content/application~1json/schema/oneOf/0" }, + { "$ref": "#/components/responses/CacheData/content/application~1json/schema/oneOf/1" } + ]}, + "Holder": { "type": "object", "additionalProperties": false, "properties": { + "data": { "$ref": "#/components/schemas/PutRequest" } + }} + } + } + }); + + let (generated, recoverable) = generate_and_census(spec); + assert!( + generated.contains("pub enum PutRequest") + && generated.contains("String(String)") + && generated.contains("Number(f64)"), + "pointer branches must expand into union variants:\n{generated}" + ); + assert!(recoverable.is_empty(), "{recoverable:?}"); +} + +#[test] +fn a_genuinely_unconstrained_value_stays_untyped() { + // The counterweight to every narrowing above: when the schema says "any + // JSON", `serde_json::Value` is the right answer and the census must call + // it faithful rather than recoverable. + let (generated, recoverable) = generate_and_census(spec_with_schemas(json!({ + "Thing": { "type": "object", "additionalProperties": false, "properties": { + "metadata": {}, + "payload": { "type": "object" } + }} + }))); + + assert!( + generated.contains("pub metadata: Option"), + "an empty schema must stay untyped:\n{generated}" + ); + assert!(recoverable.is_empty(), "{recoverable:?}"); +} From ddf2b54b30ca8a638977fdfdad230d0a49645675 Mon Sep 17 00:00:00 2001 From: James Lal Date: Wed, 26 Aug 2026 21:27:23 -0600 Subject: [PATCH 3/6] chore: check in the untyped-output census baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/untyped-census.sh` rewrites tests/conformance/untyped-report.md, which counts every generated field carrying `serde_json::Value` across the corpus and groups them by why — faithful where the schema declared an unconstrained value, recoverable where the generator dropped type information the schema had. Checking it in makes the corpus delta of a typing change visible in review the way the conformance reports already do, and `--check` fails when it is stale. The baseline stands at 8,795 untyped fields, 6 of them recoverable, all one shape (#65). Refs #62 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry --- CONTRIBUTING.md | 9 +++ scripts/untyped-census.sh | 102 ++++++++++++++++++++++++++++ tests/conformance/untyped-report.md | 78 +++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100755 scripts/untyped-census.sh create mode 100644 tests/conformance/untyped-report.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3f6213..67070db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,8 +75,17 @@ Also run the relevant distribution or corpus gate when touching these areas: scripts/install-smoke.sh # packaging, CLI, or dependencies scripts/spec-compile.sh anthropic openai # generator/client output scripts/spec-compile.sh # broad generator/type changes +scripts/untyped-census.sh # anything that changes which fields get typed ``` +`scripts/untyped-census.sh` rewrites `tests/conformance/untyped-report.md`, +which counts every generated field that carries `serde_json::Value` and says +why. Regenerate it when a change types fields that used to be opaque (or stops +typing ones that were), so the corpus delta is visible in review; +`scripts/untyped-census.sh --check` fails when it is stale. A **recoverable** +row means the schema carried type information the generator dropped — those are +defects with a fix, not shapes the spec left open. + The full corpus generates and compile-checks 55 OpenAPI documents and can take several minutes. CI runs a fast generation tier on pull requests and the full compile tier weekly or on manual dispatch. diff --git a/scripts/untyped-census.sh b/scripts/untyped-census.sh new file mode 100755 index 0000000..a0b06b6 --- /dev/null +++ b/scripts/untyped-census.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Report how much of the generated surface is still `serde_json::Value`, and +# why, across every spec under specs/. +# +# `serde_json::Value` in generated code means one of two things: the schema +# declared an unconstrained value, or the generator dropped type information the +# schema carried. Only the second is a defect, so the report separates them and +# ranks the recoverable causes — that ranking is what says where typing work is +# worth doing next. +# +# Usage: +# scripts/untyped-census.sh # rewrite the checked-in report +# scripts/untyped-census.sh --check # fail if the report is out of date +# +# Env: +# CENSUS_SPECS="a b" limit to named specs (default: everything in specs/) +set -euo pipefail +cd "$(dirname "$0")/.." + +REPORT="tests/conformance/untyped-report.md" +CHECK=0 +[ "${1:-}" = "--check" ] && CHECK=1 + +echo "[untyped-census] building openapi-to-rust binary..." >&2 +cargo build --quiet --bin openapi-to-rust + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +specs=() +if [ -n "${CENSUS_SPECS:-}" ]; then + for name in $CENSUS_SPECS; do + for candidate in "specs/$name.yaml" "specs/$name.json"; do + [ -f "$candidate" ] && specs+=("$candidate") + done + done +else + for candidate in specs/*.yaml specs/*.json; do + [ -f "$candidate" ] && specs+=("$candidate") + done +fi + +for spec in "${specs[@]}"; do + name="$(basename "$spec")"; name="${name%.*}" + if ! target/debug/openapi-to-rust generate "$spec" \ + --output-dir "$WORK/$name" --types-only --report-untyped --json \ + > "$WORK/$name.json" 2>/dev/null; then + echo "[untyped-census] skipped $name (generation failed)" >&2 + rm -f "$WORK/$name.json" + continue + fi + # The findings array is followed by the generation summary; keep the array. + jq -s '.[0]' "$WORK/$name.json" > "$WORK/$name.findings.json" +done + +{ + echo "# Untyped Output Census" + echo + echo "Generated by \`scripts/untyped-census.sh\`. Every generated field that" + echo "carries \`serde_json::Value\`, grouped by why." + echo + echo "**Faithful** means the schema declared an unconstrained value and there is" + echo "no better Rust type. **Recoverable** means the schema carried type" + echo "information that did not survive; those are defects with a fix." + echo + echo "## Corpus totals" + echo + echo "| Reason | Count | Verdict |" + echo "|---|---:|---|" + jq -s -r ' + add + | group_by(.reason) + | map({reason: .[0].reason, count: length}) + | sort_by(-.count) + | .[] + | "| `\(.reason)` | \(.count) | " + + (if (.reason | test("^(any-schema|opaque-object|untyped-additional-properties|array-without-items|open-positional-items)$")) + then "faithful" else "**recoverable**" end) + " |" + ' "$WORK"/*.findings.json + echo + echo "## Per spec" + echo + echo "| Spec | Untyped | Recoverable |" + echo "|---|---:|---:|" + for findings in "$WORK"/*.findings.json; do + name="$(basename "$findings")"; name="${name%.findings.json}" + total="$(jq 'length' "$findings")" + recoverable="$(jq '[.[] | select(.reason | test("^(any-schema|opaque-object|untyped-additional-properties|array-without-items|open-positional-items)$") | not)] | length' "$findings")" + echo "| \`$name\` | $total | $recoverable |" + done +} > "$WORK/report.md" + +if [ "$CHECK" = "1" ]; then + if ! diff -u "$REPORT" "$WORK/report.md"; then + echo "[untyped-census] $REPORT is out of date; run scripts/untyped-census.sh" >&2 + exit 1 + fi + echo "[untyped-census] ✅ report is up to date" +else + cp "$WORK/report.md" "$REPORT" + echo "[untyped-census] wrote $REPORT" +fi diff --git a/tests/conformance/untyped-report.md b/tests/conformance/untyped-report.md new file mode 100644 index 0000000..19b57e7 --- /dev/null +++ b/tests/conformance/untyped-report.md @@ -0,0 +1,78 @@ +# Untyped Output Census + +Generated by `scripts/untyped-census.sh`. Every generated field that +carries `serde_json::Value`, grouped by why. + +**Faithful** means the schema declared an unconstrained value and there is +no better Rust type. **Recoverable** means the schema carried type +information that did not survive; those are defects with a fix. + +## Corpus totals + +| Reason | Count | Verdict | +|---|---:|---| +| `opaque-object` | 4372 | faithful | +| `any-schema` | 3041 | faithful | +| `untyped-additional-properties` | 1376 | faithful | +| `unrepresentable-union` | 6 | **recoverable** | + +## Per spec + +| Spec | Untyped | Recoverable | +|---|---:|---:| +| `anthropic` | 18 | 0 | +| `arcade` | 23 | 0 | +| `asana` | 10 | 0 | +| `box` | 9 | 0 | +| `browserbase` | 5 | 0 | +| `cal-com` | 117 | 0 | +| `cartesia` | 3 | 0 | +| `cerebras` | 47 | 0 | +| `circleci` | 26 | 0 | +| `cloudflare` | 1078 | 3 | +| `coda` | 27 | 0 | +| `coingecko` | 0 | 0 | +| `datadog-v2` | 156 | 0 | +| `digitalocean` | 22 | 0 | +| `discord` | 528 | 0 | +| `gcore` | 130 | 0 | +| `github` | 182 | 0 | +| `gitpod` | 249 | 0 | +| `google-calendar` | 12 | 0 | +| `google-drive` | 11 | 0 | +| `google-gmail` | 3 | 0 | +| `google-tasks` | 0 | 0 | +| `google-youtube` | 7 | 0 | +| `grafana` | 44 | 0 | +| `groq` | 13 | 0 | +| `imagekit` | 35 | 0 | +| `increase` | 291 | 0 | +| `knocklabs` | 44 | 0 | +| `langsmith` | 380 | 0 | +| `launchdarkly` | 83 | 0 | +| `letta` | 168 | 0 | +| `lithic` | 13 | 0 | +| `luma` | 0 | 0 | +| `meta-llama` | 2 | 0 | +| `microsoft-graph` | 2315 | 0 | +| `modern-treasury` | 26 | 0 | +| `openai` | 160 | 2 | +| `opencode` | 184 | 0 | +| `pagerduty` | 40 | 0 | +| `perplexity` | 7 | 0 | +| `resend` | 13 | 0 | +| `retell` | 32 | 0 | +| `runway` | 2 | 0 | +| `sentry` | 595 | 0 | +| `snyk` | 332 | 1 | +| `spotify` | 19 | 0 | +| `storyden` | 75 | 0 | +| `stripe` | 689 | 0 | +| `supabase` | 28 | 0 | +| `telnyx` | 162 | 0 | +| `terminal-shop` | 1 | 0 | +| `together` | 85 | 0 | +| `twilio` | 34 | 0 | +| `val-town` | 18 | 0 | +| `vercel` | 237 | 0 | +| `writer` | 5 | 0 | From 8a2fc4303f6b9b9f3328a2b0945aed9654d23add Mon Sep 17 00:00:00 2001 From: James Lal Date: Wed, 26 Aug 2026 21:34:08 -0600 Subject: [PATCH 4/6] test: cover the census CLI and the remaining typing edges Adds the surface tests the typing work did not yet pin: - `--report-untyped` and its `--json` form, which is what scripts/untyped-census.sh consumes; - the composition-member pointer form (`.../Tag/allOf/0`), alongside the parameter-schema form already covered; - an unresolvable reference still degrading to a reported finding rather than failing the document, now that pointer resolution runs first; - a nullable `$ref` branch staying a real union alternative, which the null-marker rule must not swallow; - `items: true`, the other half of boolean `items`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry --- tests/recoverable_typing_test.rs | 87 +++++++++++++++++++ tests/untyped_report_cli_test.rs | 139 +++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 tests/untyped_report_cli_test.rs diff --git a/tests/recoverable_typing_test.rs b/tests/recoverable_typing_test.rs index 0225a4f..a05434d 100644 --- a/tests/recoverable_typing_test.rs +++ b/tests/recoverable_typing_test.rs @@ -375,6 +375,93 @@ fn union_branches_that_are_deep_pointers_are_expanded() { assert!(recoverable.is_empty(), "{recoverable:?}"); } +#[test] +fn a_pointer_into_a_composition_resolves_to_that_member() { + // The other pointer form PagerDuty uses: one member of another schema's + // `allOf`, addressed by index. + let (generated, recoverable) = generate_and_census(spec_with_schemas(json!({ + "Tag": { "allOf": [ + { "type": "object", "additionalProperties": false, + "properties": { "label": { "type": "string" } } } + ]}, + "Action": { "type": "object", "additionalProperties": false, "properties": { + "base": { "$ref": "#/components/schemas/Tag/allOf/0" } + }} + }))); + + assert!( + !generated.contains("pub base: Option"), + "a pointer into a composition must resolve:\n{generated}" + ); + assert!(recoverable.is_empty(), "{recoverable:?}"); +} + +#[test] +fn an_unresolvable_reference_still_degrades_rather_than_failing() { + // Pointer resolution must not turn a bad reference into a hard error: the + // rest of the document still generates, and the census reports the field so + // it can be found. + let analysis = analyze(spec_with_schemas(json!({ + "Thing": { "type": "object", "additionalProperties": false, "properties": { + "external": { "$ref": "https://example.com/schemas/Other.json" }, + "missing": { "$ref": "#/components/schemas/DoesNotExist/allOf/7" } + }} + }))); + + let reasons = analysis + .untyped_fields() + .into_iter() + .map(|finding| finding.reason) + .collect::>(); + assert!( + reasons + .iter() + .all(|reason| *reason == openapi_to_rust::analysis::UntypedReason::UnresolvedReference), + "unresolvable refs must be reported as such, got {reasons:?}" + ); +} + +#[test] +fn a_nullable_reference_branch_is_not_treated_as_null() { + // `{$ref: ..., nullable: true}` constrains its target; only an *empty* + // nullable object is the null marker. Collapsing this would pick the wrong + // branch of the union. + let (generated, _) = generate_and_census(spec_with_schemas(json!({ + "A": { "type": "object", "additionalProperties": false, + "properties": { "a": { "type": "string" } } }, + "B": { "type": "object", "additionalProperties": false, + "properties": { "b": { "type": "string" } } }, + "Holder": { "type": "object", "additionalProperties": false, "properties": { + "either": { "anyOf": [ + { "$ref": "#/components/schemas/A" }, + { "$ref": "#/components/schemas/B", "nullable": true } + ]} + }} + }))); + + assert!( + !generated.contains("pub either: Option"), + "a nullable $ref branch is a real alternative, not a null marker:\n{generated}" + ); +} + +#[test] +fn items_true_parses_as_an_unconstrained_array() { + // The other half of boolean `items`: `true` accepts anything, so the array + // is untyped but the document still parses. + let (generated, recoverable) = generate_and_census(spec_with_schemas(json!({ + "Thing": { "type": "object", "additionalProperties": false, "properties": { + "anything": { "type": "array", "items": true } + }} + }))); + + assert!( + generated.contains("pub anything: Option>"), + "`items: true` must parse and stay unconstrained:\n{generated}" + ); + assert!(recoverable.is_empty(), "{recoverable:?}"); +} + #[test] fn a_genuinely_unconstrained_value_stays_untyped() { // The counterweight to every narrowing above: when the schema says "any diff --git a/tests/untyped_report_cli_test.rs b/tests/untyped_report_cli_test.rs new file mode 100644 index 0000000..97340f4 --- /dev/null +++ b/tests/untyped_report_cli_test.rs @@ -0,0 +1,139 @@ +//! `--report-untyped` at the CLI boundary. +//! +//! The census is only useful if someone can run it on their own spec and act on +//! the answer, so these tests pin the surface: the human-readable summary names +//! the reason and the field, and `--json` emits findings a script can rank. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use tempfile::TempDir; + +/// One field the generator can type, one it cannot, and one the schema left +/// open — so the report has something to say in each column. +const SPEC: &str = r#"openapi: 3.1.0 +info: + title: untyped report + version: 1.0.0 +components: + schemas: + Thing: + type: object + additionalProperties: false + properties: + name: + type: string + metadata: + type: object +"#; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_openapi-to-rust")) +} + +fn run(dir: &Path, args: &[&str]) -> Output { + Command::new(binary()) + .current_dir(dir) + .args(args) + .output() + .expect("cli runs") +} + +fn write_spec(dir: &Path) { + std::fs::write(dir.join("api.yaml"), SPEC).expect("spec written"); +} + +#[test] +fn report_untyped_names_the_reason_and_the_field() { + let dir = TempDir::new().expect("tempdir"); + write_spec(dir.path()); + + let output = run( + dir.path(), + &[ + "generate", + "api.yaml", + "--output-dir", + "out", + "--types-only", + "--report-untyped", + ], + ); + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("untyped: 1 field(s)"), + "expected a count, got:\n{stdout}" + ); + assert!( + stdout.contains("OpaqueObject") && stdout.contains("Thing.metadata"), + "expected the reason and the field, got:\n{stdout}" + ); + assert!( + stdout.contains("faithful"), + "an unconstrained object is faithful, not a defect:\n{stdout}" + ); +} + +#[test] +fn report_untyped_json_emits_findings_for_tooling() { + let dir = TempDir::new().expect("tempdir"); + write_spec(dir.path()); + + let output = run( + dir.path(), + &[ + "generate", + "api.yaml", + "--output-dir", + "out", + "--types-only", + "--report-untyped", + "--json", + ], + ); + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + + // The findings array is printed before the generation summary; a consumer + // reads the first JSON value, which is what scripts/untyped-census.sh does. + let findings: serde_json::Value = serde_json::Deserializer::from_str(&stdout) + .into_iter() + .next() + .expect("a JSON value on stdout") + .expect("valid JSON"); + let findings = findings.as_array().expect("findings are an array"); + + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0]["context"], "Thing.metadata"); + assert_eq!(findings[0]["reason"], "opaque-object"); + assert_eq!(findings[0]["shape"], "value"); +} + +#[test] +fn a_fully_typed_spec_reports_none() { + let dir = TempDir::new().expect("tempdir"); + std::fs::write( + dir.path().join("api.yaml"), + SPEC.replace(" metadata:\n type: object\n", ""), + ) + .expect("spec written"); + + let output = run( + dir.path(), + &[ + "generate", + "api.yaml", + "--output-dir", + "out", + "--types-only", + "--report-untyped", + ], + ); + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("untyped: none"), + "expected the clean-bill message, got:\n{stdout}" + ); +} From 5cdb6fb6f92ee4ef475897817cd47b83670c1651 Mon Sep 17 00:00:00 2001 From: James Lal Date: Wed, 26 Aug 2026 22:32:29 -0600 Subject: [PATCH 5/6] fix: keep synthesized types honest about cycles and string rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing fields that used to be `serde_json::Value` exposed two latent gaps in the corpus compile gate. Stripe stopped compiling. A hoisted union declared no dependencies, so the cycle `Quote -> QuotesResourceFromQuote -> QuotesResourceFromQuoteQuote -> Quote` was invisible to recursion detection and the generated enum came out infinitely sized. The `Value` had been breaking that cycle by accident. Synthesized types now derive their dependencies from the type they hold, which also generates a struct that was previously referenced by a union variant and never emitted. OpenAI stopped compiling. An extensible enum is now the type of `CreateTranslationRequest.model`, and multipart form fields render values through `Display`, which those enums did not implement. They now expose `as_str`, `Display`, and `AsRef` — the same surface generated string enums already had, and the serializer goes through `as_str` rather than repeating the match. Snapshots: four show the new enum surface, one now includes a struct that was referenced but missing, and two differ only in item order, which the dependency edges changed. Refs #62 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry --- src/analysis.rs | 73 +++++++++++++++++-- src/generator.rs | 23 +++++- ...lpers__discriminator_array_standalone.snap | 22 +++--- ...o_rust__test_helpers__extensible_enum.snap | 20 ++++- ...elpers__extensible_enum_serialization.snap | 20 ++++- ..._rust__test_helpers__ideal_const_enum.snap | 20 ++++- ..._rust__test_helpers__model_anyof_test.snap | 20 ++++- ...st_helpers__property_underscore_types.snap | 10 +-- ...ust__test_helpers__union_array_naming.snap | 8 +- tests/recoverable_typing_test.rs | 71 ++++++++++++++++++ 10 files changed, 245 insertions(+), 42 deletions(-) diff --git a/src/analysis.rs b/src/analysis.rs index b21c74e..4d394bc 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -149,6 +149,68 @@ impl UntypedReason { } } +/// The schemas a generated type refers to. +/// +/// Synthesized types — a hoisted property type, a named union — need these to +/// be accurate, not merely non-empty: the dependency graph is what +/// `detect_recursive_schemas` reads, and a cycle that runs through a +/// synthesized type is invisible without them. Stripe's +/// `Quote → QuotesResourceFromQuote → QuotesResourceFromQuoteQuote → Quote` +/// compiled to an infinitely-sized enum until the middle link declared where it +/// pointed. +fn schema_type_dependencies(schema_type: &SchemaType) -> HashSet { + let mut targets = HashSet::new(); + collect_type_dependencies(schema_type, &mut targets, 0); + targets +} + +fn collect_type_dependencies( + schema_type: &SchemaType, + targets: &mut HashSet, + depth: usize, +) { + if depth > UNTYPED_WALK_DEPTH { + return; + } + match schema_type { + SchemaType::Reference { target } => { + targets.insert(target.clone()); + } + SchemaType::Array { item_type } => collect_type_dependencies(item_type, targets, depth + 1), + SchemaType::Tuple { element_types } => { + for element_type in element_types { + collect_type_dependencies(element_type, targets, depth + 1); + } + } + SchemaType::Object { + properties, + additional_properties, + .. + } => { + for property in properties.values() { + collect_type_dependencies(&property.schema_type, targets, depth + 1); + } + if let ObjectAdditionalProperties::Typed { value_type } = additional_properties { + collect_type_dependencies(value_type, targets, depth + 1); + } + } + SchemaType::Union { variants } | SchemaType::Composition { schemas: variants } => { + for variant in variants { + targets.insert(variant.target.clone()); + } + } + SchemaType::DiscriminatedUnion { variants, .. } => { + for variant in variants { + targets.insert(variant.type_name.clone()); + } + } + SchemaType::Primitive { .. } + | SchemaType::StringEnum { .. } + | SchemaType::ExtensibleEnum { .. } + | SchemaType::Untyped { .. } => {} + } +} + /// Convert any `serde_json::Value` still carried as a stringly-typed /// `Primitive` into [`SchemaType::Untyped`]. /// @@ -2434,8 +2496,8 @@ impl SchemaAnalyzer { AnalyzedSchema { name: union_type_name.clone(), original: serde_json::to_value(prop_schema).unwrap_or(Value::Null), + dependencies: schema_type_dependencies(&union_schema_type), schema_type: union_schema_type, - dependencies: HashSet::new(), nullable: false, description: prop_schema.details().description.clone(), default: None, @@ -4745,20 +4807,17 @@ impl SchemaAnalyzer { return schema_type; } - let description = match &schema_type { - SchemaType::Object { .. } => None, - _ => None, - }; let hoisted_name = self.unique_hoisted_name(schema_name, property_name); + let hoisted_dependencies = schema_type_dependencies(&schema_type); self.resolved_cache.insert( hoisted_name.clone(), AnalyzedSchema { name: hoisted_name.clone(), original: Value::Null, schema_type, - dependencies: dependencies.clone(), + dependencies: hoisted_dependencies, nullable: false, - description, + description: None, default: None, }, ); diff --git a/src/generator.rs b/src/generator.rs index c6256e2..196db29 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1729,11 +1729,28 @@ impl CodeGenerator { where S: serde::Serializer, { - let value = match self { + serializer.serialize_str(self.as_str()) + } + } + + impl #enum_name { + pub fn as_str(&self) -> &str { + match self { #(#match_arms_ser)* #enum_name::Custom(s) => s.as_str(), - }; - serializer.serialize_str(value) + } + } + } + + impl ::std::fmt::Display for #enum_name { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(self.as_str()) + } + } + + impl AsRef for #enum_name { + fn as_ref(&self) -> &str { + self.as_str() } } }) diff --git a/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap b/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap index ffbc086..3eafa93 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap @@ -17,17 +17,6 @@ pub struct CreateMessageParams { #[serde(skip_serializing_if = "Option::is_none")] pub system: Option, } -/// Wrapper enum that re-adds the discriminator tag -/// for array contexts where the inner struct had its -/// discriminator field stripped for tagged enum use. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "type")] -pub enum RequestTextBlockArrayItem { - #[serde(rename = "text")] - RequestTextBlock(RequestTextBlock), -} -///Array variant in union -pub type RequestTextBlockArray = Vec; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "type")] pub enum InputContentBlock { @@ -59,3 +48,14 @@ pub enum CreateMessageParamsSystem { String(String), RequestTextBlockArray(RequestTextBlockArray), } +/// Wrapper enum that re-adds the discriminator tag +/// for array contexts where the inner struct had its +/// discriminator field stripped for tagged enum use. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type")] +pub enum RequestTextBlockArrayItem { + #[serde(rename = "text")] + RequestTextBlock(RequestTextBlock), +} +///Array variant in union +pub type RequestTextBlockArray = Vec; diff --git a/src/snapshots/openapi_to_rust__test_helpers__extensible_enum.snap b/src/snapshots/openapi_to_rust__test_helpers__extensible_enum.snap index 0570bfa..d221d84 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__extensible_enum.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__extensible_enum.snap @@ -44,12 +44,26 @@ impl serde::Serialize for Model { where S: serde::Serializer, { - let value = match self { + serializer.serialize_str(self.as_str()) + } +} +impl Model { + pub fn as_str(&self) -> &str { + match self { Model::Claude3Opus => "claude-3-opus", Model::Claude3Sonnet => "claude-3-sonnet", Model::Claude3Haiku => "claude-3-haiku", Model::Custom(s) => s.as_str(), - }; - serializer.serialize_str(value) + } + } +} +impl ::std::fmt::Display for Model { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(self.as_str()) + } +} +impl AsRef for Model { + fn as_ref(&self) -> &str { + self.as_str() } } diff --git a/src/snapshots/openapi_to_rust__test_helpers__extensible_enum_serialization.snap b/src/snapshots/openapi_to_rust__test_helpers__extensible_enum_serialization.snap index 15e44b8..ab3eb34 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__extensible_enum_serialization.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__extensible_enum_serialization.snap @@ -44,12 +44,26 @@ impl serde::Serialize for Model { where S: serde::Serializer, { - let value = match self { + serializer.serialize_str(self.as_str()) + } +} +impl Model { + pub fn as_str(&self) -> &str { + match self { Model::Gpt4 => "gpt-4", Model::Gpt35Turbo => "gpt-3.5-turbo", Model::Claude3Opus => "claude-3-opus", Model::Custom(s) => s.as_str(), - }; - serializer.serialize_str(value) + } + } +} +impl ::std::fmt::Display for Model { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(self.as_str()) + } +} +impl AsRef for Model { + fn as_ref(&self) -> &str { + self.as_str() } } diff --git a/src/snapshots/openapi_to_rust__test_helpers__ideal_const_enum.snap b/src/snapshots/openapi_to_rust__test_helpers__ideal_const_enum.snap index 34f65af..7562fa2 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__ideal_const_enum.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__ideal_const_enum.snap @@ -36,12 +36,26 @@ impl serde::Serialize for FlexibleEnum { where S: serde::Serializer, { - let value = match self { + serializer.serialize_str(self.as_str()) + } +} +impl FlexibleEnum { + pub fn as_str(&self) -> &str { + match self { FlexibleEnum::KnownValue1 => "known_value_1", FlexibleEnum::KnownValue2 => "known_value_2", FlexibleEnum::Custom(s) => s.as_str(), - }; - serializer.serialize_str(value) + } + } +} +impl ::std::fmt::Display for FlexibleEnum { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(self.as_str()) + } +} +impl AsRef for FlexibleEnum { + fn as_ref(&self) -> &str { + self.as_str() } } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] diff --git a/src/snapshots/openapi_to_rust__test_helpers__model_anyof_test.snap b/src/snapshots/openapi_to_rust__test_helpers__model_anyof_test.snap index 634471c..26b9c88 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__model_anyof_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__model_anyof_test.snap @@ -42,11 +42,25 @@ impl serde::Serialize for Model { where S: serde::Serializer, { - let value = match self { + serializer.serialize_str(self.as_str()) + } +} +impl Model { + pub fn as_str(&self) -> &str { + match self { Model::Claude35SonnetLatest => "claude-3-5-sonnet-latest", Model::Claude35HaikuLatest => "claude-3-5-haiku-latest", Model::Custom(s) => s.as_str(), - }; - serializer.serialize_str(value) + } + } +} +impl ::std::fmt::Display for Model { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(self.as_str()) + } +} +impl AsRef for Model { + fn as_ref(&self) -> &str { + self.as_str() } } diff --git a/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap b/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap index 6e7ad07..be4bb77 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__property_underscore_types.snap @@ -23,6 +23,11 @@ pub enum ConfigObjectDisplaySettings { String(String), HeightBlock(HeightBlock), } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HeightBlock { + pub height: i64, + pub width: i64, +} #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub enum ConfigObjectCacheControl { #[default] @@ -46,8 +51,3 @@ impl AsRef for ConfigObjectCacheControl { self.as_str() } } -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct HeightBlock { - pub height: i64, - pub width: i64, -} diff --git a/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap b/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap index 4e23844..734ef42 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap @@ -30,16 +30,16 @@ pub enum RequestToolResultBlockContentItemUnion { pub struct RequestTextBlock { pub text: String, } -///Array variant in union -pub type RequestToolResultBlockContentArray = Vec< - RequestToolResultBlockContentItemUnion, ->; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum RequestToolResultBlockContent { String(String), RequestToolResultBlockContentArray(RequestToolResultBlockContentArray), } +///Array variant in union +pub type RequestToolResultBlockContentArray = Vec< + RequestToolResultBlockContentItemUnion, +>; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RequestImageBlock { pub source: RequestImageBlockSource, diff --git a/tests/recoverable_typing_test.rs b/tests/recoverable_typing_test.rs index a05434d..4391985 100644 --- a/tests/recoverable_typing_test.rs +++ b/tests/recoverable_typing_test.rs @@ -462,6 +462,77 @@ fn items_true_parses_as_an_unconstrained_array() { assert!(recoverable.is_empty(), "{recoverable:?}"); } +#[test] +fn a_hoisted_type_declares_what_it_points_at() { + // Typing a field that used to be `serde_json::Value` can close a reference + // cycle that the `Value` had broken by accident. Recursion detection reads + // the dependency graph, so a synthesized type that declares no dependencies + // makes its half of the cycle invisible and the generated enum comes out + // infinitely sized (Stripe: Quote -> QuotesResourceFromQuote -> + // QuotesResourceFromQuoteQuote -> Quote). + let analysis = analyze(spec_with_schemas(json!({ + "Quote": { "type": "object", "additionalProperties": false, "properties": { + "from_quote": { "$ref": "#/components/schemas/FromQuote" } + }}, + "FromQuote": { "type": "object", "additionalProperties": false, "properties": { + "quote": { "anyOf": [{ "type": "string" }, { "$ref": "#/components/schemas/Quote" }] } + }} + }))); + + let hoisted = analysis + .schemas + .get("FromQuoteQuote") + .expect("the union is hoisted under the parent and property name"); + assert!( + hoisted.dependencies.contains("Quote"), + "a hoisted union must declare the schemas it points at, got {:?}", + hoisted.dependencies + ); + // The generated form is where it bites: an unboxed variant here is a type + // of infinite size and the crate does not compile. + let generated = generate(spec_with_schemas(json!({ + "Quote": { "type": "object", "additionalProperties": false, "properties": { + "from_quote": { "$ref": "#/components/schemas/FromQuote" } + }}, + "FromQuote": { "type": "object", "additionalProperties": false, "properties": { + "quote": { "anyOf": [{ "type": "string" }, { "$ref": "#/components/schemas/Quote" }] } + }} + }))); + assert!( + generated.contains("Quote(Box)"), + "the cycle through the hoisted type must be boxed:\n{generated}" + ); +} + +#[test] +fn an_extensible_enum_renders_as_a_string_everywhere_a_string_is_expected() { + // Typing an open enum moves a field from `String` to a generated enum, and + // multipart form fields, query parameters, and headers all render values + // through `Display`. Without it the generated client stops compiling + // (OpenAI's `CreateTranslationRequest.model`). + let generated = generate(spec_with_schemas(json!({ + "Model": { "anyOf": [ + { "type": "string" }, + { "type": "string", "enum": ["whisper-1"] } + ]}, + "Request": { "type": "object", "additionalProperties": false, + "properties": { "model": { "$ref": "#/components/schemas/Model" } } } + }))); + + assert!( + generated.contains("impl ::std::fmt::Display for Model"), + "an extensible enum must implement Display:\n{generated}" + ); + assert!( + generated.contains("pub fn as_str(&self) -> &str"), + "an extensible enum must expose its wire value:\n{generated}" + ); + assert!( + generated.contains("impl AsRef for Model"), + "an extensible enum must borrow as a str:\n{generated}" + ); +} + #[test] fn a_genuinely_unconstrained_value_stays_untyped() { // The counterweight to every narrowing above: when the schema says "any From d3b8b690d0352659e20da1e709f98218433888c3 Mon Sep 17 00:00:00 2001 From: James Lal Date: Wed, 26 Aug 2026 22:55:48 -0600 Subject: [PATCH 6/6] fix: read a nullable object as "or null" only beside a reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `anyOf: [X, {type: object, nullable: true}]` is ambiguous. OData emits it for every navigation property meaning "X, or null", and reading the second branch literally there costs X its type — that is what the previous commit fixed. But read literally the branch says "or any object", and the corpus has 33 fields written as `anyOf: [{type: string, nullable: true}, {type: object, nullable: true}]`, where the objects are real. Typing those as `Option` would compile and then fail on a payload the schema plainly allows, which is the failure mode 0.12.3 fixed for nullability. The empty-object spelling is now read as a null marker only beside a `$ref`, where the intent is not in doubt; `type: "null"` still says so on its own and needs no sibling. Microsoft Graph keeps all 2,127 of its narrowings, the 33 scalar cases stay honest, and the corpus recoverable count is unchanged at 6. Refs #62 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry --- src/openapi.rs | 63 ++++++++++++++++------------- tests/conformance/untyped-report.md | 4 +- tests/recoverable_typing_test.rs | 23 +++++++++++ 3 files changed, 60 insertions(+), 30 deletions(-) diff --git a/src/openapi.rs b/src/openapi.rs index 0b5dea3..b973928 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -854,46 +854,53 @@ impl Schema { /// Check if this appears to be a nullable pattern (anyOf or oneOf with null) pub fn is_nullable_pattern(&self) -> bool { - let variants = match self { - Schema::AnyOf { any_of, .. } => any_of, - Schema::OneOf { one_of, .. } => one_of, - _ => return false, - }; - variants.len() == 2 - && variants.iter().any(Schema::is_null_marker) - && variants.iter().any(|s| !s.is_null_marker()) + self.non_null_variant().is_some() } - /// Get the non-null variant from a nullable pattern + /// The one meaningful branch of a two-branch union whose other branch + /// exists only to admit `null`. pub fn non_null_variant(&self) -> Option<&Schema> { - if !self.is_nullable_pattern() { - return None; - } let variants = match self { Schema::AnyOf { any_of, .. } => any_of, Schema::OneOf { one_of, .. } => one_of, _ => return None, }; - variants.iter().find(|s| !s.is_null_marker()) + let [first, second] = variants.as_slice() else { + return None; + }; + match ( + Self::is_null_marker_beside(first, second), + Self::is_null_marker_beside(second, first), + ) { + (true, false) => Some(second), + (false, true) => Some(first), + // Two markers describe nothing, and no marker is not this pattern. + _ => None, + } } - /// Whether this branch of a union exists only to admit `null`. + /// Whether `candidate` exists only to admit `null` alongside `sibling`. /// - /// 3.1 spells that `type: "null"`. Tooling that predates it spells it as an - /// empty object carrying `nullable: true` — OData emits - /// `anyOf: [$ref, {type: object, nullable: true}]` for every navigation - /// property, which is "that type, or null", not "that type or any object". - /// Reading the second branch literally costs the first branch its type: - /// the union has no single Rust representation and the field degrades to - /// `serde_json::Value`. + /// 3.1 spells that `type: "null"`, which says so on its own. Tooling that + /// predates it spells it as an empty object carrying `nullable: true`, and + /// that spelling is ambiguous: read literally, + /// `anyOf: [X, {type: object, nullable: true}]` is "X, any object, or + /// null". /// - /// Only an *empty* nullable object qualifies. A nullable branch that - /// constrains anything — properties, `additionalProperties`, a `$ref`, an - /// enum — is a real alternative and is left alone. - pub fn is_null_marker(&self) -> bool { - if matches!(self.schema_type(), Some(SchemaType::Null)) { - return true; - } + /// The empty-object spelling is read as a null marker only beside a + /// `$ref`, which is the shape + /// OData emits for every navigation property and where the intent is not + /// in doubt. Beside a scalar the literal reading wins — the corpus has 33 + /// `anyOf: [{type: string, nullable: true}, {type: object, nullable: true}]` + /// fields, and typing those as `Option` would fail to deserialize + /// the objects the schema plainly allows. + fn is_null_marker_beside(candidate: &Schema, sibling: &Schema) -> bool { + matches!(candidate.schema_type(), Some(SchemaType::Null)) + || (candidate.is_empty_nullable_object() && sibling.reference().is_some()) + } + + /// An object schema carrying `nullable: true` and constraining nothing. + fn is_empty_nullable_object(&self) -> bool { let details = self.details(); if !details.is_nullable() { return false; diff --git a/tests/conformance/untyped-report.md b/tests/conformance/untyped-report.md index 19b57e7..cd4c1a1 100644 --- a/tests/conformance/untyped-report.md +++ b/tests/conformance/untyped-report.md @@ -11,7 +11,7 @@ information that did not survive; those are defects with a fix. | Reason | Count | Verdict | |---|---:|---| -| `opaque-object` | 4372 | faithful | +| `opaque-object` | 4405 | faithful | | `any-schema` | 3041 | faithful | | `untyped-additional-properties` | 1376 | faithful | | `unrepresentable-union` | 6 | **recoverable** | @@ -29,7 +29,7 @@ information that did not survive; those are defects with a fix. | `cartesia` | 3 | 0 | | `cerebras` | 47 | 0 | | `circleci` | 26 | 0 | -| `cloudflare` | 1078 | 3 | +| `cloudflare` | 1111 | 3 | | `coda` | 27 | 0 | | `coingecko` | 0 | 0 | | `datadog-v2` | 156 | 0 | diff --git a/tests/recoverable_typing_test.rs b/tests/recoverable_typing_test.rs index 4391985..bd354e7 100644 --- a/tests/recoverable_typing_test.rs +++ b/tests/recoverable_typing_test.rs @@ -108,6 +108,29 @@ fn a_nullable_branch_that_constrains_something_stays_a_union() { ); } +#[test] +fn a_nullable_object_beside_a_scalar_is_read_literally() { + // The narrowing above is deliberately limited to a `$ref` sibling, where + // OData's intent is not in doubt. Beside a scalar, `{type: object, + // nullable: true}` means what it says — "or any object, or null" — and 33 + // corpus fields are written that way. Typing them as `Option` would + // fail on the objects the schema allows. + let (generated, _) = generate_and_census(spec_with_schemas(json!({ + "Thing": { "type": "object", "additionalProperties": false, "properties": { + "value": { "anyOf": [ + { "type": "string", "nullable": true }, + { "type": "object", "nullable": true } + ]} + }} + }))); + + assert!( + !generated.contains("pub value: Option"), + "a nullable object beside a scalar is a real alternative, not a null \ + marker:\n{generated}" + ); +} + #[test] fn open_string_enum_union_becomes_an_extensible_enum() { // Anthropic's `AnthropicBeta`: a named set of values, plus any other string.