Skip to content

fix: object unions drop data and select the wrong variant (#70) - #72

Merged
lightsofapollo merged 3 commits into
mainfrom
fix/opaque-object-union-branch
Sep 8, 2026
Merged

fix: object unions drop data and select the wrong variant (#70)#72
lightsofapollo merged 3 commits into
mainfrom
fix/opaque-object-union-branch

Conversation

@lightsofapollo

@lightsofapollo lightsofapollo commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #70, plus two further defects in the same family found while verifying it. Three commits, each independently reviewable.

1. An unconstrained object member of a type: [...] union dropped every key (#70)

Every member of type: [string, number, boolean, object, array, "null"] shares one SchemaDetails, so the object member can carry no object shape at all. It was still projected as a closed, empty struct:

pub struct GetEventResponsePayloadObject {}

{"payload":{"key":"value"}} deserialized without error and serialized back as {"payload":{}}.

Root cause. The single-type path already guards this — analyze_single_typed_schema's Object arm checks should_use_dynamic_json first. The typed-multi path had no equivalent, and couldn't reuse is_dynamic_object_pattern because that starts from schema_type(), which on a TypedMulti reports only the first non-null member (string, here), so the guard could never fire. The object-shape half is now split into object_shape_is_unconstrained(&SchemaDetails) and both callers use it.

An unconstrained member gets BTreeMap<String, serde_json::Value> — the shape this codebase already uses for an untyped map (UntypedShape::ValueMap). Crucially it matches only objects, so the untagged enum still routes arrays and scalars to their own members; serde_json::Value would swallow them. The generated type name is unchanged.

2. A type: object union branch claimed values belonging to later branches

p: { anyOf: [ {type: object}, {type: string} ] }

The object branch became pub type WVariant = serde_json::Value, which matches every JSON shape, so WP::String was unreachable for every input:

branch carrier {"p": "hello"} deserializes to
serde_json::Value (before) Some(WVariant(String("hello")))
BTreeMap<String, serde_json::Value> (after) Some(String("hello"))

Applied at add_inline_union_branch_schema, the single choke point every union-branch path funnels through. Keyed on UntypedReason::OpaqueObject alone — a branch declaring no type ({}, true, {nullable: true}) genuinely admits any JSON and keeps serde_json::Value. That distinction is pinned as a negative case in the same spec as the positive one.

3. A closed struct in union-branch position swallowed keys the union could hold

The Microsoft Graph shape anyOf: [$ref User, {type: object, nullable: true}], with User stating additionalProperties: false and all-optional fields, still round-tripped {"user":{"free":"shape"}} as {"user":{}} after fixes 1 and 2. User matched any object, won the branch ahead of the map, and ate the key — even though the document forbids that key on User.

This required splitting a conflated model first. ObjectAdditionalProperties::Forbidden meant two different things:

what Forbidden meant corpus object schemas JSON Schema says
stated additionalProperties: false 7,157 closed
keyword omitted, projected closed 59,287 open

Keying strictness on Forbidden would have made 59k open objects reject keys the spec permits. Split into Denied (stated) and Closed (projected); the 16 read sites that only care whether a catch-all field exists now say so via is_open().

#[serde(deny_unknown_fields)] is emitted only when all three hold: the document stated it (Denied), the struct is reachable as an untagged union branch, and there is no flattened variant — serde rejects deny_unknown_fields alongside flatten at compile time. A closed struct outside any union has no branch to lose and stays tolerant, so generated clients keep working when a server adds a field.

Measured, not assumed

spec structs gained the attribute
anthropic 450 41 (9.1%)
cloudflare 11,425 34 (0.3%)
openai 1,244 6 (0.5%)
box 635 2 (0.3%)
asana 631 0
stripe 4,984 0
total 19,369 83 (0.4%)

Zero of 19,019 scanned generated struct blocks pair deny_unknown_fields with flatten, and anthropic's generated module — the highest concentration — compiles.

Verification

Three new test files, each pairing codegen assertions with a scratch crate that compiles the generated code and round-trips real JSON:

  • unconstrained_object_union_member_test.rs — every member of the issue's union round-trips; shaped and additionalProperties: false members keep their structs.
  • opaque_object_union_branch_test.rs — a string reaches String(_) past the object branch; a typeless branch stays serde_json::Value.
  • closed_union_branch_strictness_test.rs — the attribute lands on exactly one of three structs; an undeclared key reaches the open branch and survives; a closed standalone struct still tolerates a newly added server field.

cargo test --all-features (686 tests, 0 failures), cargo clippy --all-features -- -D warnings, and cargo fmt --check are clean.

One pinned test changed behavior: recoverable_typing_test.rs::odata_nullable_reference_union_keeps_its_literal_object_branch. Its intent — don't collapse the union, keep a dynamic branch — is unchanged; only the carrier's spelling moves. I confirmed the explicit null that branch admits still hydrates before touching the assertion.

Deliberately not changed

  • An omitted additionalProperties still drops undeclared keys on valid input ({"open":{"zzz":1}}{"open":{}}). That is the generator's documented closed-model projection; preserving those keys means adding a flatten carrier to ~59k corpus schemas. A pre-existing tradeoff, not a regression.
  • Discriminated-union variants do not gain deny_unknown_fields. They are selected by tag value, so branch laxness does not misroute them the same way.

🤖 Generated with Claude Code

https://claude.ai/code/session_012mBAVbYp2iUSL8VwpyDf19

lightsofapollo and others added 2 commits September 8, 2026 07:02
All members of a `type: [...]` union share one `SchemaDetails`, so the
`object` member may carry no object shape at all. It was still routed
through `add_allocated_object_schema`, which projected it as a closed,
empty struct: `{"payload": {"key": "value"}}` deserialized fine and then
serialized back as `{"payload": {}}`, silently dropping every key.

The single-type path already guards this with `should_use_dynamic_json`;
the typed-multi path did not. Split the object-shape half of
`is_dynamic_object_pattern` into `object_shape_is_unconstrained` so the
union member can ask the same question — `schema_type()` reports only
the first non-null member of a `type: [...]` list, so that caller cannot
reuse the type check — and give an unconstrained member a
`BTreeMap<String, serde_json::Value>` carrier. Unlike `serde_json::Value`
the map matches only objects, so the untagged enum keeps routing arrays
and scalars to their own members.

Members the spec does shape (`properties`, `required`,
`additionalProperties`, `minProperties`/`maxProperties`, and the rest)
keep their generated structs.

The regression test compiles the generated code in a scratch crate and
round-trips every member of the issue's union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mBAVbYp2iUSL8VwpyDf19
`serde_json::Value` matches every JSON shape, so an untagged union branch
typed that way claims values belonging to a later branch: with
`anyOf: [{type: object}, {type: string}]` a JSON string deserialized into
the object branch and never reached `String(String)`. Verified both ways
against the compiled generated code — `Some(WVariant(String("hello")))`
before, `Some(String("hello"))` after.

A branch that *declares* `type: object` is not "any JSON", so narrow it to
`BTreeMap<String, serde_json::Value>`: equally lossless, and it matches
only objects. Applied at `add_inline_union_branch_schema`, the single
choke point every union-branch path funnels through.

Only `UntypedReason::OpaqueObject` is narrowed. A branch that declares no
type at all (`{}`, `true`, `{nullable: true}`) really does admit any JSON
and keeps `serde_json::Value` — pinned as the negative case in the new
test, because that distinction is the whole reason the reason code exists.

`odata_nullable_reference_union_keeps_its_literal_object_branch` pinned
the old spelling. Its intent — don't collapse the union, keep a dynamic
branch — is unchanged; only the carrier moves. The explicit `null` that
`{type: object, nullable: true}` admits is still carried by the field's
own `Option`, verified before the assertion was touched.

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

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
openapi-to-rust Ready Ready Preview Sep 8, 2026 2:07pm UTC

Request Review

@lightsofapollo lightsofapollo changed the title fix: keep an opaque object union branch from claiming other branches fix: object unions silently drop data and select the wrong variant (#70) Sep 8, 2026
@lightsofapollo
lightsofapollo changed the base branch from fix/unconstrained-object-union-member to main September 8, 2026 13:43
`#[serde(untagged)]` takes the first branch that deserializes, so a
branch accepting more than its schema allows claims values belonging to
a later one. A struct stating `additionalProperties: false` whose fields
are all optional matched *any* object, won the branch ahead of the one
that actually accepts the value, and dropped the extra keys —
`{"user": {"free": "shape"}}` round-tripped as `{"user": {}}` even
though the union's open branch was right there.

Fixing it first required splitting a conflated model.
`ObjectAdditionalProperties::Forbidden` meant both "the document stated
`additionalProperties: false`" and "the keyword was omitted, so the
generator projects a closed struct". Those are different facts, and
across the 57-spec corpus they are 7,157 and 59,287 object schemas
respectively — keying strictness on `Forbidden` would have made 59k
JSON-Schema-*open* objects reject keys the spec permits. Split into
`Denied` (stated) and `Closed` (projected); the 16 read sites that only
care whether a catch-all field exists now say so via `is_open()`.

`#[serde(deny_unknown_fields)]` is then emitted only when all three
hold: the document stated it (`Denied`), the struct is reachable as an
untagged union branch, and there is no flattened variant (serde rejects
`deny_unknown_fields` alongside `flatten` at compile time). A closed
struct outside any union has no branch to lose and stays tolerant, so
generated clients keep working when a server adds a field.

Measured, not assumed: 83 of 19,369 structs across six large corpus
specs gain the attribute (0.4%; anthropic 41/450, stripe 0/4,984), zero
of 19,019 scanned struct blocks pair it with `flatten`, and anthropic's
generated module compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mBAVbYp2iUSL8VwpyDf19
@lightsofapollo lightsofapollo changed the title fix: object unions silently drop data and select the wrong variant (#70) fix: object unions drop data and select the wrong variant (#70) Sep 8, 2026
@lightsofapollo
lightsofapollo merged commit 715e9e6 into main Sep 8, 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.

[bug]: Unconstrained object in a multi-type schema silently drops properties

1 participant