Skip to content

fix(codegen): read newtype structs as the value they wrap - #217

Merged
hardbyte merged 6 commits into
mainfrom
brian/cool-maxwell-8p8zj1
Nov 8, 2026
Merged

hardbyte merged 6 commits into
mainfrom
brian/cool-maxwell-8p8zj1

Conversation

@hardbyte

@hardbyte hardbyte commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

The next #129 slice after #212: "Newtype structs in Layout". Building it turned up a wider bug in OpenAPI, which this also fixes. Rebased on main after #218–#221.

Serde writes a newtype struct as the value it wraps, whether it is flattened or not: struct UserId(Uuid) is "...", and #[serde(flatten)] w: Wrapper with struct Wrapper(Inner) writes Inner's keys next to the parent's. Rust, TypeScript and Python clients already read newtypes this way. OpenAPI and Layout didn't.

One rule for wrappers

wrapped_field in codegen/schema/shape.rs says which field a struct is written as when it wraps one: a newtype, or a #[serde(transparent)] struct with one field. Three call sites read it:

  • Layout
  • OpenAPI's struct schema
  • Python's flattened-wrapper rewrite (below)

Before this PR, Layout and OpenAPI each handled transparent structs themselves, and neither handled newtypes. #219's nullability.rs keeps its own walk, because it also substitutes generic arguments and checks each field's codec.

OpenAPI: a newtype struct is the schema it wraps

struct_to_schema described a newtype struct as a one-element array, {"type": "array", "prefixItems": [T]}. A client generated from the spec sent [value], which the server rejects. The demo's own pets.create request body, PetsCreateRequest(Pet), was [Pet]. It's now the inner schema, the same as a transparent struct.

Unit structs change too. The schema describes struct U; as a newtype of (), so OpenAPI showed it as [null], and it's now null, which is what serde writes.

Layout: a flattened newtype has the keys of the type it wraps

A flattened newtype was Layout::Unknown, so:

  • Python codegen failed with "cannot flatten 'Wrapper' into a model".
  • OpenAPI described the group with the array above, which no object matches.

Now the layout follows the newtype into its inner type, as it already did for transparent structs. Python expands the inner keys onto the parent. OpenAPI writes allOf with the inner schema, or for Option<Wrapper>, a oneOf of the inner schema and "none of its keys".

Python: a flattened wrapper of an enum renders as the enum

From Codex's review. Python decides how to render a flattened field from the type the field names. Behind a newtype or transparent struct it missed an enum, and generated a nested event: Event field where serde writes the tag and fields on the parent.

  • Newtype wrapper: the Layout change above would have turned main's codegen error into that wrong model.
  • Transparent wrapper: already had this bug on main, unless the schema was built with fold_transparent_types().

Before rendering a struct, Python now rewrites each flattened field that names a wrapper to name the wrapped type. serde writes both forms identically.

Output

The Python, TypeScript and Rust demo clients, plus the enum compatibility and field presence fixtures, are byte-identical to main. Only OpenAPI output and flattened wrappers change. The codegen coverage fixture changes because this PR adds Span and Parent to it.

The changed snapshots are all OpenAPI ones:

In each one a prefixItems array becomes the wrapped value. The wrapped_field refactor changes no output: all 67 generated files are identical before and after it.

Testing

  • New snapshot, serde__flatten_newtype_struct: a required and an optional flattened newtype, across all five outputs.
  • Wire replay. The codegen coverage fixture's WireRequest now flattens a required newtype Span and an optional Parent, which alternates Some and None. CI checks that serde's JSON reaches the transport unchanged through the generated Python client, round-trips, and validates against both Python JSON schemas.
  • OpenAPI against serde, a local check that isn't committed. I validated the fixture's serde samples against the generated OpenAPI WireRequest schema with jsonschema: 6 of 6 valid on this branch, 0 of 6 on main ("is not of type 'array'").
  • flattening_a_wrapper_of_an_enum_renders_the_enum: a newtype and a transparent wrapper of an internally tagged enum each render the same Outer classes as flattening the enum directly. It fails without the fix, for each wrapper.
  • Unit tests:
    • wrapped_field: newtype, one-field transparent, single named field and pair.
    • Layout: a newtype struct has the keys of the struct it wraps.
    • Two existing tests used a one-field tuple as their example of an unsupported type. They now use a two-field tuple.
  • Passing locally:
    • cargo fmt --check, clippy -D warnings --all-targets --all-features, and cargo test --workspace --all-features
    • every Python codegen smoke step on 3.14, and on 3.12 with pydantic 2.10.0

Also in the changelog

#212 changed one case without an entry: a renamed single-field tuple variant, A(#[serde(rename = "foo")] u32). It's listed under Unreleased now.

Not changed

Renamed tuple fields in the derive. The derive stores a serde rename as a tuple field's name, although serde ignores it. Name-based checks (is_named, is_tuple, TypeScript, Rust) then read it as a named field. On main and here alike, TypeScript codegen emits code that doesn't parse for struct S(#[serde(rename = "foo")] u32). That's a separate fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VjkNsfHSd5osRevK95McpQ

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-03T11:37:05.475492Z 06ffc7f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 06ffc7f506

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread reflectapi/src/codegen/schema/layout.rs Resolved
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

📖 Documentation Preview: https://reflectapi-docs-preview-pr-217.partly.workers.dev

Updated automatically from commit 4459c9e

hardbyte pushed a commit that referenced this pull request Nov 3, 2026
Python picks how to render a flattened field from the type the field
names. Behind a newtype or transparent struct, an enum was missed, so the
model had a nested field (`event: Event`) where serde writes the tag and
fields on the parent. Before #217 a newtype failed codegen instead; a
transparent wrapper had this bug unless the schema was built with
`fold_transparent_types()`.

The struct is now rendered with each such field naming the wrapped type,
which serde writes identically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjkNsfHSd5osRevK95McpQ
Serde writes a newtype struct as the value it wraps, flattened or not.
`Shape::Newtype` now says so in the two places that didn't:

- OpenAPI described `struct UserId(Uuid)` as a one-element array
  (`prefixItems`), so a client generated from the spec sent `["..."]`.
  It is now the inner schema, as for a transparent struct. Unit structs,
  a newtype of `()` in the schema, are `null` instead of `[null]`.
- `Layout` treated a flattened newtype as having unknown keys, so Python
  codegen failed and OpenAPI described the group with that array. It now
  contributes the keys of the type it wraps.

The codegen coverage fixture flattens a required and an optional newtype,
so CI replays serde's JSON through the generated Python models and checks
their JSON schemas against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjkNsfHSd5osRevK95McpQ
Python picks how to render a flattened field from the type the field
names. Behind a newtype or transparent struct, an enum was missed, so the
model had a nested field (`event: Event`) where serde writes the tag and
fields on the parent. Before #217 a newtype failed codegen instead; a
transparent wrapper had this bug unless the schema was built with
`fold_transparent_types()`.

The struct is now rendered with each such field naming the wrapped type,
which serde writes identically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjkNsfHSd5osRevK95McpQ
#219 and #220 added fixtures with newtype and unit structs, whose OpenAPI
components were one-element arrays; they are now the wrapped value, like
every other newtype here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjkNsfHSd5osRevK95McpQ
…ield

`Layout`, OpenAPI's struct schema and Python's flattened-wrapper rewrite
each decided for themselves that a newtype, or a transparent struct with
one field, is written as the field it wraps. `wrapped_field` in
`codegen/schema/shape.rs` now decides it once. Generated output is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjkNsfHSd5osRevK95McpQ
@hardbyte
hardbyte force-pushed the brian/cool-maxwell-8p8zj1 branch from debfe2a to ad38c71 Compare November 6, 2026 03:24

@antonzherdev antonzherdev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM and the agent also did not find anything real.

@hardbyte
hardbyte merged commit 5cf5317 into main Nov 8, 2026
11 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.

3 participants