diff --git a/CHANGELOG.md b/CHANGELOG.md index 046f5a1..4e31edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,29 @@ 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; + - a schema declaring `properties` *and* a union — "these fields, and one of + these shapes" — generates the struct with the union in a + `#[serde(flatten)]` field, instead of discarding both halves (#65). - `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/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/src/analysis.rs b/src/analysis.rs index 1e89922..fd207e0 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -103,6 +103,416 @@ 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, + } + } +} + +/// 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`]. +/// +/// 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 { + 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, + &property_context, + 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 } => { + 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() { + 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, + /// 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. + 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, + // 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 + | 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 { @@ -191,6 +601,11 @@ pub enum SchemaType { properties: BTreeMap, required: HashSet, additional_properties: ObjectAdditionalProperties, + /// A union the schema declares *alongside* its own properties — + /// `{properties: {...}, anyOf: [...]}` — meaning "these fields, and + /// one of these shapes". Held in a `#[serde(flatten)]` field so both + /// halves survive; `None` for a plain object. + variant: Option, }, /// Discriminated union (oneOf + discriminator) DiscriminatedUnion { @@ -215,6 +630,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 @@ -1077,9 +1502,44 @@ 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 { + /// 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") @@ -1115,6 +1575,7 @@ impl SchemaAnalyzer { current_schema_name: None, component_parameters, type_mapper, + resolving_pointers: HashSet::new(), }) } @@ -1371,6 +1832,10 @@ impl SchemaAnalyzer { } } + for schema in analysis.schemas.values_mut() { + normalize_untyped(&mut schema.schema_type, 0); + } + Ok(analysis) } @@ -1717,10 +2182,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(); @@ -1728,13 +2193,20 @@ impl SchemaAnalyzer { SchemaType::Reference { target } } None => { - eprintln!( - "⚠️ unresolvable $ref `{}` — typing as serde_json::Value", - reference - ); - SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, + 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, + ) } } } @@ -1789,6 +2261,33 @@ 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(), + }); + } + if let Some(schema_type) = self.analyze_object_with_variants( + schema, + any_of, + schema_name, + &mut dependencies, + )? { + return Ok(AnalyzedSchema { + name: schema_name.to_string(), + original: serde_json::to_value(schema).unwrap_or(Value::Null), + schema_type, + dependencies, + nullable, + description, + default: details.default.clone(), + }); + } // Handle anyOf patterns (nullable vs flexible union vs discriminated) self.analyze_anyof_union( any_of, @@ -1802,13 +2301,24 @@ impl SchemaAnalyzer { discriminator, .. } => { - // Handle oneOf discriminated unions - self.analyze_oneof_union( + if one_of.is_empty() { + self.analyze_empty_union(schema, &mut dependencies)? + } else if let Some(schema_type) = self.analyze_object_with_variants( + schema, one_of, - discriminator.as_ref(), schema_name, &mut dependencies, - )? + )? { + schema_type + } 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) @@ -1820,10 +2330,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 +2343,19 @@ impl SchemaAnalyzer { values: details.string_enum_values().unwrap_or_default(), } } - _ => SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), + // `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, + ), } } else { - SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - } + self.untyped_value(self.untyped_context(""), UntypedReason::AnySchema) } } }; @@ -1903,16 +2416,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, + // `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, }, }) @@ -1932,18 +2444,36 @@ 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 { // Check if this property is a union that needs a named type let prop_type = if let Schema::AnyOf { any_of, .. } = prop_schema { - // First check if this should be a dynamic JSON pattern - if self.should_use_dynamic_json(prop_schema) { + // The union may sit alongside the property's own + // properties, or constrain only which of them are + // required — OpenAI's `tool_resources.file_search` is + // `{properties: {...}, anyOf: [{required: [a]}, {required: [b]}]}`. + if Self::union_only_constrains_requiredness(any_of) { + self.analyze_empty_union(prop_schema, dependencies)? + } else if let Some(with_variants) = self.analyze_object_with_variants( + prop_schema, + any_of, + &format!("{owner_name}{}", self.to_pascal_case(prop_name)), + dependencies, + )? { + with_variants + } else 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() { @@ -2006,8 +2536,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, @@ -2040,6 +2570,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(); @@ -2121,6 +2661,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(); @@ -2179,6 +2726,7 @@ impl SchemaAnalyzer { Ok(SchemaType::Object { properties: property_info, + variant: None, required, additional_properties, }) @@ -2275,14 +2823,21 @@ 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 ); - 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 +2986,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 { @@ -2477,9 +3030,11 @@ impl SchemaAnalyzer { target: object_type_name, }); } - _ => { + // `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: "serde_json::Value".to_string(), + rust_type: self.type_mapper.null_unit().rust_type, serde_with: None, }); } @@ -2499,10 +3054,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 +3172,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 +3215,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( @@ -2718,6 +3265,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(); @@ -2810,6 +3370,7 @@ impl SchemaAnalyzer { properties: merged_properties, required: merged_required, additional_properties: ObjectAdditionalProperties::Forbidden, + variant: None, }) } else { // Fall back to composition if we couldn't merge @@ -2853,6 +3414,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 @@ -2893,6 +3464,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 { @@ -3221,10 +3815,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 +4008,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( @@ -3992,15 +4586,391 @@ 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), }) } 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, + ), + ) + } + } + + /// 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) + } + + /// Analyze a schema that declares its own `properties` *and* a union. + /// + /// `{properties: {...}, anyOf: [A, B]}` means "these fields, and one of + /// these shapes" — Cloudflare's DLP entries and OpenAI's `file_search` + /// resources are written this way. Neither half can be dropped: reading + /// only the union loses the declared fields, and reading only the object + /// loses the alternatives, which is why this used to generate + /// `serde_json::Value`. + /// + /// The object is generated as a struct and the union as its own enum, held + /// in a `#[serde(flatten)]` field. Returns `None` when the schema has no + /// properties of its own, leaving plain unions to the union analyzers. + fn analyze_object_with_variants( + &mut self, + schema: &Schema, + branches: &[Schema], + schema_name: &str, + dependencies: &mut HashSet, + ) -> Result> { + let details = schema.details(); + if details.properties.as_ref().is_none_or(BTreeMap::is_empty) || branches.is_empty() { + return Ok(None); + } + // A nullable wrapper is not a variant set, and requiredness-only + // branches are handled before this. + if schema.is_nullable_pattern() || Self::union_only_constrains_requiredness(branches) { + return Ok(None); + } + + let base = self.analyze_object_schema(schema, dependencies)?; + let SchemaType::Object { + properties, + required, + additional_properties, + .. + } = base + else { + return Ok(None); + }; + + let variant_name = self.unique_hoisted_name(schema_name, "Variant"); + let variant_type = self.analyze_anyof_union( + branches, + schema.discriminator(), + dependencies, + &variant_name, + )?; + // If the union itself has no representation, keep the object rather + // than flattening something untyped into it. + if matches!(variant_type, SchemaType::Untyped { .. }) { + return Ok(Some(SchemaType::Object { + properties, + required, + additional_properties, + variant: None, + })); + } + + self.resolved_cache.insert( + variant_name.clone(), + AnalyzedSchema { + name: variant_name.clone(), + original: Value::Null, + dependencies: schema_type_dependencies(&variant_type), + schema_type: variant_type, + nullable: false, + description: None, + default: None, + }, + ); + dependencies.insert(variant_name.clone()); + + Ok(Some(SchemaType::Object { + properties, + required, + additional_properties, + variant: Some(SchemaRef { + target: variant_name, + nullable: false, + }), + })) + } + + /// 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 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: hoisted_dependencies, + nullable: false, + description: None, + 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; } } @@ -4026,12 +4996,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 }); } @@ -4053,10 +5029,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 +5141,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 +5239,26 @@ impl SchemaAnalyzer { rust_type: "bool".to_string(), serde_with: None, }, - _ => SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), + // `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, + ), } } 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, - }, + // 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) @@ -4310,6 +5287,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` @@ -4325,10 +5312,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 @@ -4340,6 +5324,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| { @@ -4569,12 +5568,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; } } @@ -4595,10 +5609,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: "#" @@ -5879,6 +6893,7 @@ impl SchemaAnalyzer { properties, required, additional_properties, + .. } = &resolved.schema_type else { return None; @@ -5976,6 +6991,7 @@ impl SchemaAnalyzer { properties, required, additional_properties, + .. } = schema_type else { return None; @@ -6120,6 +7136,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 @@ -6604,6 +7639,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/client_generator.rs b/src/client_generator.rs index a1929b9..6fe99b9 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -989,6 +989,7 @@ impl CodeGenerator { properties, required, additional_properties, + .. } if !self.is_discriminated_variant(resolved_name, analysis) => { let emitted = self.emitted_object_properties( resolved_name, @@ -1071,6 +1072,7 @@ impl CodeGenerator { properties, required, additional_properties, + .. } if !self.is_discriminated_variant(schema_name, analysis) => { for field in self.emitted_object_properties( schema_name, @@ -2535,6 +2537,7 @@ impl CodeGenerator { properties, required, additional_properties, + .. } = &resolved_schema.schema_type else { let message = diff --git a/src/generator.rs b/src/generator.rs index 2f3549e..52967bb 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -366,6 +366,35 @@ pub struct CodeGenerator { source_provenance: Option, } +/// The parts of an analyzed object a struct is generated from, bundled so the +/// generator's entry point keeps a readable signature. +struct ObjectShape<'a> { + properties: &'a BTreeMap, + required: &'a std::collections::HashSet, + additional_properties: &'a crate::analysis::ObjectAdditionalProperties, + /// A union declared alongside the properties, flattened into the struct. + variant: Option<&'a crate::analysis::SchemaRef>, +} + +/// 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 { @@ -1446,11 +1475,15 @@ impl CodeGenerator { properties, required, additional_properties, + variant, } => self.generate_struct( schema, - properties, - required, - additional_properties, + ObjectShape { + properties, + required, + additional_properties, + variant: variant.as_ref(), + }, analysis, type_context, ), @@ -1502,6 +1535,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)); @@ -1707,11 +1743,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() } } }) @@ -1873,15 +1926,43 @@ impl CodeGenerator { }) } + /// Field name for a flattened variant union, avoiding any property the + /// schema already declares. + fn variant_field_name( + &self, + properties: &BTreeMap, + ) -> String { + let taken = |candidate: &str| { + properties + .keys() + .any(|name| self.to_rust_field_name(name) == candidate) + }; + if !taken("variant") { + return "variant".to_string(); + } + let mut suffix = 2; + loop { + let candidate = format!("variant{suffix}"); + if !taken(&candidate) { + return candidate; + } + suffix += 1; + } + } + fn generate_struct( &self, schema: &crate::analysis::AnalyzedSchema, - properties: &BTreeMap, - required: &std::collections::HashSet, - additional_properties: &crate::analysis::ObjectAdditionalProperties, + object: ObjectShape<'_>, analysis: &crate::analysis::SchemaAnalysis, type_context: &TypeGenerationContext<'_>, ) -> Result { + let ObjectShape { + properties, + required, + additional_properties, + variant, + } = object; let struct_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); let emitted_properties = self.emitted_object_properties( &schema.name, @@ -1954,6 +2035,20 @@ impl CodeGenerator { } } + // A schema that declares `properties` *and* a union means "these + // fields, and one of these shapes". The union rides in a flattened + // field so both halves round-trip: serde reads the declared properties + // and hands the remaining keys to the variant enum. + if let Some(variant) = variant { + let variant_type = format_ident!("{}", self.to_rust_type_name(&variant.target)); + let variant_field = format_ident!("{}", self.variant_field_name(properties)); + fields.push(quote! { + /// The variant this value takes, alongside the fields above. + #[serde(flatten)] + pub #variant_field: #variant_type, + }); + } + let doc_comment = if let Some(desc) = &schema.description { quote! { #[doc = #desc] } } else { @@ -1965,9 +2060,12 @@ impl CodeGenerator { // additional-properties map (when present) is empty by default. We do // not invent values for required data, even when the Rust type itself // happens to implement Default. - let can_derive_default = emitted_properties - .iter() - .all(|property| !property.is_required); + // A flattened variant is one of several shapes, and picking one would + // invent data the same way a required field would. + let can_derive_default = variant.is_none() + && emitted_properties + .iter() + .all(|property| !property.is_required); // Generate derives with optional Specta support // Note: We use snake_case everywhere (matching the OpenAPI spec) for consistency @@ -1990,6 +2088,7 @@ impl CodeGenerator { }; let builder = if type_context.index.request_body_roots.contains(&schema.name) + && variant.is_none() && emitted_properties .iter() .any(|property| property.is_required) @@ -2733,6 +2832,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 +3571,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/openapi.rs b/src/openapi.rs index 5c8ab66..b973928 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 @@ -834,30 +854,80 @@ 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(|s| matches!(s.schema_type(), Some(SchemaType::Null))) + 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| !matches!(s.schema_type(), Some(SchemaType::Null))) + 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 `candidate` exists only to admit `null` alongside `sibling`. + /// + /// 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". + /// + /// 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; + } + 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/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/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/conformance/untyped-report.md b/tests/conformance/untyped-report.md new file mode 100644 index 0000000..b9aa7f4 --- /dev/null +++ b/tests/conformance/untyped-report.md @@ -0,0 +1,77 @@ +# 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` | 4413 | faithful | +| `any-schema` | 3040 | faithful | +| `untyped-additional-properties` | 1376 | faithful | + +## 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` | 1115 | 0 | +| `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` | 10 | 0 | +| `luma` | 0 | 0 | +| `meta-llama` | 2 | 0 | +| `microsoft-graph` | 2315 | 0 | +| `modern-treasury` | 26 | 0 | +| `openai` | 158 | 0 | +| `opencode` | 184 | 0 | +| `pagerduty` | 42 | 0 | +| `perplexity` | 7 | 0 | +| `resend` | 13 | 0 | +| `retell` | 32 | 0 | +| `runway` | 2 | 0 | +| `sentry` | 595 | 0 | +| `snyk` | 331 | 0 | +| `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` | 238 | 0 | +| `writer` | 5 | 0 | diff --git a/tests/recoverable_typing_test.rs b/tests/recoverable_typing_test.rs new file mode 100644 index 0000000..f508a5d --- /dev/null +++ b/tests/recoverable_typing_test.rs @@ -0,0 +1,673 @@ +//! 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 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. + 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_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_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 an_object_that_also_declares_variants_keeps_both_halves() { + // `{properties: {...}, anyOf: [A, B]}` means "these fields, and one of + // these shapes" — Cloudflare's DLP entries. Neither half can be dropped, so + // the object generates a struct and the union its own enum, held in a + // flattened field (#65). + assert_types( + spec_with_schemas(json!({ + "Profile": { "type": "object", "additionalProperties": false, + "properties": { "id": { "type": "string" } } }, + "CustomEntry": { "type": "object", "additionalProperties": false, + "properties": { "pattern": { "type": "string" } } }, + "PredefinedEntry": { "type": "object", "additionalProperties": false, + "properties": { "preset": { "type": "string" } } }, + "Entry": { + "properties": { + "profiles": { "type": "array", "items": { "$ref": "#/components/schemas/Profile" } }, + "upload_status": { "type": "string" } + }, + "required": ["profiles"], + "anyOf": [ + { "$ref": "#/components/schemas/CustomEntry" }, + { "$ref": "#/components/schemas/PredefinedEntry" } + ] + } + })), + &[ + "pub struct Entry", + "pub profiles: Vec", + "#[serde(flatten)]", + "pub variant: EntryVariant", + "pub enum EntryVariant", + ], + ); +} + +#[test] +fn a_struct_with_a_flattened_variant_derives_no_default() { + // `Default` on the struct would have to invent a variant, which is the same + // problem as inventing a required field. Deriving it anyway does not + // compile, since the untagged enum has no default either. + let generated = generate(spec_with_schemas(json!({ + "A": { "type": "object", "additionalProperties": false, + "properties": { "a": { "type": "string" } } }, + "B": { "type": "object", "additionalProperties": false, + "properties": { "b": { "type": "string" } } }, + "Holder": { + "properties": { "note": { "type": "string" } }, + "anyOf": [ + { "$ref": "#/components/schemas/A" }, + { "$ref": "#/components/schemas/B" } + ] + } + }))); + + let struct_start = generated + .find("pub struct Holder") + .expect("the struct is generated"); + let derive_line = generated[..struct_start] + .lines() + .rev() + .find(|line| line.contains("#[derive(")) + .expect("a derive line precedes the struct"); + assert!( + !derive_line.contains("Default"), + "a struct holding a flattened variant must not derive Default: {derive_line}" + ); +} + +#[test] +fn a_requiredness_only_union_inside_a_property_is_the_object_it_describes() { + // The same shape one level down, which the named-schema check did not see: + // OpenAI's `tool_resources.file_search` is an inline object whose `anyOf` + // only alternates which of its own fields are required. + assert_types( + spec_with_schemas(json!({ + "Request": { "type": "object", "additionalProperties": false, "properties": { + "file_search": { + "type": "object", + "properties": { + "vector_store_ids": { "type": "array", "items": { "type": "string" } }, + "vector_stores": { "type": "array", "items": { "type": "string" } } + }, + "anyOf": [ + { "required": ["vector_store_ids"] }, + { "required": ["vector_stores"] } + ] + } + }} + })), + &[ + "pub vector_store_ids: Option>", + "pub vector_stores: Option>", + ], + ); +} + +#[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:?}"); +} 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:?}" + ); +} 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}" + ); +}