Skip to content

feat: find and fix every schema that degrades to serde_json::Value (#62, #65) - #67

Merged
lightsofapollo merged 8 commits into
mainfrom
feat/untyped-census
Aug 27, 2026
Merged

feat: find and fix every schema that degrades to serde_json::Value (#62, #65)#67
lightsofapollo merged 8 commits into
mainfrom
feat/untyped-census

Conversation

@lightsofapollo

Copy link
Copy Markdown
Contributor

Summary

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. Nothing distinguished them, so nobody could say which of the
corpus's untyped fields were defects.

This adds the instrument, then uses it. Corpus-wide recoverable untyped fields
go from 3,347 to zero.

The instrument

SchemaType::Untyped { shape, reason } carries the reason in the IR, so the
census is derived from the types that actually get generated rather than
recorded as analysis runs — a schema referenced by fifty properties counts fifty
times, and a pruned one counts zero. (Recording on the side got both wrong, by
47%.)

$ openapi-to-rust generate api.yaml --report-untyped
untyped: 46 field(s), 0 of them recoverable
    44  UntypedAdditionalProperties  faithful   e.g. AssistantMessageRequest.<additionalProperties>
     1  AnySchema                    faithful   e.g. TcpWarmingV1TcpWarmingGetResponse
     1  OpaqueObject                 faithful   e.g. JSONSchema.schema

scripts/untyped-census.sh runs it over the corpus and rewrites
tests/conformance/untyped-report.md, so a typing change shows its delta in
review; --check fails when the baseline is stale.

A normalization pass reports anything that reaches the IR still stringly-typed
as Unclassified — a visible gap in the taxonomy rather than a missing count.
The corpus has none.

What it found, and what this fixes

Cause Before Now
inline-union-dropped 2,182 0
unrepresentable-union 592 0
inline-object-dropped 342 0
inline-composition-dropped 102 0
unrepresentable-composition 55 0
inline-enum-dropped 51 0
unresolved-reference 15 0
unsupported-type-keyword 8 0

Two root causes dominated:

  • One idiom. OData spells a nullable reference
    anyOf: [$ref, {type: object, nullable: true}], and reading the second branch
    literally makes the union unrepresentable, costing the first branch its type:
    2,127 fields in Microsoft Graph alone. Those become Option<T>.
  • One structural gap. A struct field can hold a primitive, a reference, an
    array, or a tuple; an inline object, union, enum, or merged allOf has to be
    generated as its own item. Analysis understood those schemas and left them in
    field positions, where the generator emitted serde_json::Value. They are now
    hoisted to named types — in property, array-element, and tuple-element
    positions alike.

The rest were narrower gaps, each traced to a real document: single-member
allOf (Box), allOf inside array items (Asana), $ref to any local JSON
Pointer (PagerDuty), type: null() (Discord), oneOf: [] beside a real
type (Discord), single-branch unions (gcore), branches differing only in
pattern/format (Runway, gcore), branches that only alternate required
(Cloudflare), and pointer branches expanded before the union is built
(PagerDuty).

Where I deliberately stopped narrowing

{type: object, nullable: true} is read as a null marker only beside a
$ref
. Read literally it also says "or any object", and 33 corpus fields are
anyOf: [{type: string, nullable: true}, {type: object, nullable: true}] where
the objects are real — typing those as Option<String> would compile and then
fail on a payload the schema allows. Microsoft Graph keeps all 2,127 of its
narrowings; those 33 stay serde_json::Value and are reported as faithful.

The last shape standing was a base object combined with a variant union —
{properties: {...}, anyOf: [A, B]}, "these fields, and one of these shapes".
Reading only the union loses the fields and reading only the object loses the
alternatives, so the generator did neither. It now generates the struct with the
union in a #[serde(flatten)] field, and both halves round-trip byte-identically
(verified against a compiled scratch crate, since flatten has sharp edges).
That closes #65.

Generated compatibility

  • Generated model or method signatures: substantially, and that is the
    point.
    4,404 fields across the corpus move from serde_json::Value to a
    real type. Consumers of those fields will need to update; the wire format is
    unchanged.
  • Query/path/header/body wire behavior: unchanged. serde reads and writes the
    narrowed types identically — Option<T> for a nullable reference, () for
    type: null (which is null on the wire), tuples as arrays.
  • Generated runtime dependencies or features: none. Extensible enums now
    implement Display/AsRef<str> and expose as_str, matching what generated
    string enums already had — needed because a multipart field can now be an
    enum.
  • Configuration defaults or migrations: none.
  • Remaining unsupported OpenAPI shapes: boolean subschemas outside items
    (Boolean subschemas (true/false) fail to parse outside items #63). Everything the census can attribute is now typed.

Validation

  • Added or updated a focused fixture and behavioral regression test.
    tests/recoverable_typing_test.rs covers every pattern above, each naming
    the spec it came from, asserting both the generated type and that the
    census no longer reports the field. Negative cases are pinned as hard as
    positive ones: a nullable branch that constrains something stays a union,
    a nullable object beside a scalar is read literally, and an unconstrained
    schema stays serde_json::Value. tests/untyped_census_test.rs covers the
    taxonomy; tests/untyped_report_cli_test.rs covers the CLI surface.
  • Reviewed every changed snapshot; no unrelated churn. Four show the new
    enum surface, one now includes a struct that was previously referenced by
    a union variant and never generated, and two differ only in item order,
    which the dependency edges changed.
  • cargo fmt --check
  • cargo clippy --all-features -- -D warnings
  • cargo test --all-features
  • RUSTDOCFLAGS=-Dwarnings cargo doc --no-deps --all-features
  • Ran scripts/install-smoke.sh for packaging/dependency changes.
  • Ran a targeted or full scripts/spec-compile.sh for generator changes
    (full corpus).
  • Updated README, rustdoc, or changelog for user-visible behavior.

The corpus gate earned its keep repeatedly here. Typing these fields exposed
three latent problems it caught and this branch fixes: a hoisted union that
declared no dependencies made a reference cycle invisible to recursion detection
(Stripe generated an infinitely-sized enum); extensible enums lacked the
Display that multipart form fields render through (OpenAI stopped compiling);
and a struct that flattens a variant can derive neither Default nor a request
builder, since both would have to invent a variant (Cloudflare, GitHub,
LaunchDarkly, Lithic).

Notes for reviewers

Smallest useful review path: UntypedReason and SchemaType::renders_inline
in src/analysis.rs (the taxonomy and the capability it mirrors), then
hoist_inline_property_type (the structural fix), then non_null_variant in
src/openapi.rs (the narrowing, and its limit).

🤖 Generated with Claude Code

https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry

lightsofapollo and others added 8 commits August 26, 2026 17:51
`serde_json::Value` in generated code means one of two things: the schema
declared an unconstrained value, or the generator dropped type information the
schema carried. Only the second is a defect, and nothing distinguished them —
across the corpus there are 13k untyped positions and no way to rank them.

Carry the reason in the IR. `SchemaType::Untyped { shape, reason }` replaces
the stringly-typed `Primitive { rust_type: "serde_json::Value" }` fallbacks, so
the census is derived from the types that get generated rather than recorded as
analysis runs: a schema referenced by fifty properties counts fifty times, and
a pruned one counts zero. Recording it on the side got both of those wrong, by
47% corpus-wide.

`--report-untyped` groups a spec's untyped fields by reason and marks each
faithful or recoverable; `--json` emits them with paths for corpus tooling.

A normalization pass converts any fallback that still builds its type from a
TypeMapper string, reporting it as `Unclassified` — a visible gap in the
taxonomy rather than a missing count. The corpus currently has none.

Corpus-wide, of 10,619 findings: opaque-object 5,488, any-schema 3,080,
untyped-additional-properties 1,376 — all faithful — against 675 recoverable,
dominated by unions (592).

The census accounts for ~81% of untyped positions in generated output. The
rest come from the generator's own render fallbacks, which this does not yet
see: a single-branch `allOf` around a scalar generates `serde_json::Value`
while the census reports the spec as fully typed. That is the next seam.

Refs #62

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
The untyped census showed 3,347 of 13,226 untyped fields as recoverable —
schemas that said enough to type and were typed as `serde_json::Value` anyway.
This takes that to 6.

The largest by far was a single idiom. OData spells a nullable reference
`anyOf: [$ref, {type: object, nullable: true}]`, and reading the second branch
literally makes the union unrepresentable, costing the first branch its type:
2,127 fields in Microsoft Graph. An empty nullable object is a null marker, so
those become `Option<T>`.

The next largest was structural rather than semantic. A struct field can hold a
primitive, a reference, an array, or a tuple; an inline object, union, enum, or
merged `allOf` has to be generated as its own item. Analysis understood those
schemas and left them in field positions, where the generator — with nothing it
could write — emitted `serde_json::Value`. They are now hoisted to named types,
in property, array-element, and tuple-element positions alike.

The rest were narrower gaps, each traced to a real document:

- `allOf` with one member is that member (Box hangs a description off a scalar
  this way), and array items never handled `allOf` at all (Asana);
- a `$ref` to any local pointer now resolves, not just component schemas
  (PagerDuty references a parameter's schema and one member of a composition);
- `type: null` is `()` rather than unknown (Discord);
- `oneOf: []` alongside a real `type` takes that type (Discord), a union of one
  branch is that branch (gcore), branches differing only in `pattern` or
  `format` share their wire type (Runway, gcore), branches that only alternate
  `required` describe the object beside them (Cloudflare), and pointer branches
  are expanded before the union is built (PagerDuty).

Each of these has a regression test naming the spec it came from, asserting
both the generated type and that the census no longer reports the field. The
negative cases are pinned too: a nullable branch that constrains something
stays a union, and an unconstrained schema stays `serde_json::Value`.

What remains is one shape — a base object combined with a variant union — which
needs a generated form the crate does not have. Filed as #65.

Refs #62

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
`scripts/untyped-census.sh` rewrites tests/conformance/untyped-report.md, which
counts every generated field carrying `serde_json::Value` across the corpus and
groups them by why — faithful where the schema declared an unconstrained value,
recoverable where the generator dropped type information the schema had.

Checking it in makes the corpus delta of a typing change visible in review the
way the conformance reports already do, and `--check` fails when it is stale.

The baseline stands at 8,795 untyped fields, 6 of them recoverable, all one
shape (#65).

Refs #62

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
Adds the surface tests the typing work did not yet pin:

- `--report-untyped` and its `--json` form, which is what
  scripts/untyped-census.sh consumes;
- the composition-member pointer form (`.../Tag/allOf/0`), alongside the
  parameter-schema form already covered;
- an unresolvable reference still degrading to a reported finding rather than
  failing the document, now that pointer resolution runs first;
- a nullable `$ref` branch staying a real union alternative, which the
  null-marker rule must not swallow;
- `items: true`, the other half of boolean `items`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
Typing fields that used to be `serde_json::Value` exposed two latent gaps in
the corpus compile gate.

Stripe stopped compiling. A hoisted union declared no dependencies, so the
cycle `Quote -> QuotesResourceFromQuote -> QuotesResourceFromQuoteQuote ->
Quote` was invisible to recursion detection and the generated enum came out
infinitely sized. The `Value` had been breaking that cycle by accident.
Synthesized types now derive their dependencies from the type they hold, which
also generates a struct that was previously referenced by a union variant and
never emitted.

OpenAI stopped compiling. An extensible enum is now the type of
`CreateTranslationRequest.model`, and multipart form fields render values
through `Display`, which those enums did not implement. They now expose
`as_str`, `Display`, and `AsRef<str>` — the same surface generated string enums
already had, and the serializer goes through `as_str` rather than repeating the
match.

Snapshots: four show the new enum surface, one now includes a struct that was
referenced but missing, and two differ only in item order, which the dependency
edges changed.

Refs #62

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
`anyOf: [X, {type: object, nullable: true}]` is ambiguous. OData emits it for
every navigation property meaning "X, or null", and reading the second branch
literally there costs X its type — that is what the previous commit fixed. But
read literally the branch says "or any object", and the corpus has 33 fields
written as `anyOf: [{type: string, nullable: true}, {type: object, nullable:
true}]`, where the objects are real. Typing those as `Option<String>` would
compile and then fail on a payload the schema plainly allows, which is the
failure mode 0.12.3 fixed for nullability.

The empty-object spelling is now read as a null marker only beside a `$ref`,
where the intent is not in doubt; `type: "null"` still says so on its own and
needs no sibling. Microsoft Graph keeps all 2,127 of its narrowings, the 33
scalar cases stay honest, and the corpus recoverable count is unchanged at 6.

Refs #62

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
`{properties: {...}, anyOf: [A, B]}` says "these fields, and one of these
shapes". Reading only the union loses the declared fields and reading only the
object loses the alternatives, so the generator did neither and emitted
`serde_json::Value` — the last shape the census still called recoverable.

The object now generates its struct and the union its own enum, held in a
`#[serde(flatten)]` field. Both halves round-trip: a payload carrying the
declared fields plus a variant's fields deserializes into the struct and its
variant, and re-serializes to the same JSON, verified against a compiled
scratch crate rather than asserted on generated text.

The same shape occurs one level down, where the named-schema check could not
see it: OpenAI's `tool_resources.file_search` is an inline object whose `anyOf`
only alternates which of its own fields are required. Property positions now
run both checks.

Corpus recoverable untyped fields: 6 to 0.

Closes #65

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
A struct holding a flattened variant cannot derive `Default` — the untagged
enum has no default, and choosing one would invent data the same way a required
field would. The generated request builder has the same problem: it constructs
the struct field by field and has no variant to supply.

Both are now skipped when a variant is present, which is what Cloudflare,
GitHub, LaunchDarkly, and Lithic needed to compile again.

Refs #65

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
@vercel

vercel Bot commented Aug 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openapi-to-rust Ready Ready Preview Aug 27, 2026 6:06am

Request Review

@lightsofapollo
lightsofapollo merged commit fd74fa7 into main Aug 27, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Base object combined with a variant union generates serde_json::Value

1 participant