GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — fix: lower a v1 allOf of a non-object $ref to the member's type by mromaszewicz · Pull Request #2591 · oapi-codegen/oapi-codegen

Conversation

@mromaszewicz

@mromaszewicz mromaszewicz commented Sep 29, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Member

What was wrong. Under schema-merging-behavior: v1, an allOf member that is a $ref is embedded in a struct, whatever its type. encoding/json flattens only an embedded struct, and encodes an embedded field of any other type under a key named after the type. So for an untyped, primitive, array or map member, allOf: [$ref: Name] sent {"Name":"x"} instead of "x". Decoding the value the spec describes failed, or, for a map, left it empty. This happened in every position: models, properties, items, map values, request and response bodies, and the client and strict server. It included the common 3.0 idiom allOf: [$ref: Status, {nullable: true}] for decorating a $ref. An untyped member next to members with properties added a spurious "Untyped" key to every object.

What changed.

  • An allOf of one such $ref, next to members that add no fields, is now the member's type, as under v2: type Wrap = Name, One *Name, nullable.Nullable[Name].
  • An untyped member places no constraint and carries no value of its own, so it never decides the composition's type: a primitive, array or map member next to it is the composition's type, and a composition of only untyped members is the first one's type (v2 and v3 give any for both). Whenever another member adds fields or brings a type of its own, the untyped member keeps its embedded field, so code that sets it still compiles, but it is tagged json:"-"; it used to add an "Untyped" key to every object.
  • A member is classified by the Go type the generator declares for the schema it refers to, and each classification is remembered in the generation context. Without that, a member that is itself an allOf of a $ref is generated twice per link, and a chain of such compositions takes time exponential in its length: 24 links took a minute, and a 200-link chain now generates in a third of a second. A member whose type may have JSON methods the struct inherits keeps today's embedding, because that already works on the wire. Examples are time.Time, openapi_types.UUID, and an x-go-type naming a type or an interface with methods.
  • Two guards keep v1's struct where aliasing would break code that compiles today:
    • Alias cycles. Some members would make a cycle of aliases, such as type Root = Arr with type Arr = []Root, which Go rejects. v2 and v3 generate exactly that today; v1 keeps its struct there.
    • Recursion guard. Classification generates the member in a context whose merge frames are copies. Otherwise, generating it could mark a composition in progress as referred back to, which turns a valid spec into "recursive allOf composition at the root … not supported".
  • The aggregate matrix harness's echo now fills a Body-wrapped strict envelope (fix: handle untyped strict response bodies #2514), so a shape with an interface-typed body runs at every position. Only the generated harnesses change for the existing shapes.

This changes the Go types of these v1 compositions from struct { Name } to the member's type. Code that sets .Name needs updating. Those types never serialized correctly, so the old shape isn't preserved behind an option.

Not covered: an allOf whose member is an inline array (the matrix's allof_recursive_array) is a different v1 bug, and stays marked broken.

How it's tested.

  • Aggregate matrix:
    • allof_array, allof_default and allof_unique_items were marked broken under v1 for exactly this bug. The marks are removed and v1 now round-trips them.
    • New shapes cover a map member, an untyped member, two untyped members, an untyped member next to a map, and an untyped member next to properties, under v1, v2 and v3.
    • All eight v1 cells fail with main's generator.
  • servers/strict/responsebody_v1 and the v1 case of TestStrictServerComposedResponseReceiverCompatibility, both from fix: handle untyped strict response bodies #2514, recorded the old v1 shape. They now expect the value itself on the wire.
  • pkg/codegen/merge_schemas_v1_members_test.go:
    • covers 13 kinds of member (alias or kept), a chained composition, a 60-link chain (which hangs without the memoization), the alias-cycle guard, context isolation, the untyped-member rules, and the classifier;
    • removing the isolation or the cycle guard fails its test (checked by mutation).
  • Every generate target with v1 forced: I generated every //go:generate target under internal/test and examples with a binary built from main and one from this branch. That's 382 targets, 220 of them forced to v1 where the config doesn't pin a version. 372 are identical, and no target changes between generating and failing. The 10 that differ are the eight matrix cells, responsebody_v1, and schemas/nullable forced to v1, whose allOf: [$ref: DecoratableValue, {nullable: true}] goes from nullable.Nullable[struct{ DecoratableValue }] to nullable.Nullable[DecoratableValue], as v2 generates.
  • Standard checks: make generate changes no other committed file. make test passes, make lint gives 0 issues, and the changed tests pass under Go 1.25.

Fixes #2590

🤖 Generated with Claude Code

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

greptile-apps[bot] reacted with thumbs up emoji
All reactions
  • 👍 1 reaction
@mromaszewicz
mromaszewicz requested a review from a team as a code owner September 29, 2026 03:01
@mromaszewicz mromaszewicz added the bug Something isn't working label Sep 29, 2026
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Fixes code generation for OpenAPI allOf schemas with non-object references.

The PR appears safe to merge based on the reviewed changes.

Summary

The PR corrects v1 allOf handling for references to non-struct values and expands round-trip coverage.

  • Lowers eligible compositions to the referenced value type and keeps untyped embedded members out of object JSON where appropriate.
  • Adds recursion and classification coverage, matrix cases, and updated strict-response expectations.

Reviews (2) · Last reviewed commit: "fix: lower a v1 allOf of a non-object $r..."

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

greptile-apps[bot]
greptile-apps Bot reviewed Sep 29, 2026
View reviewed changes
Comment thread pkg/codegen/merge_schemas_v1.go Resolved

Uh oh!

There was an error while loading. Please reload this page.

@mromaszewicz @claude
fix: lower a v1 allOf of a non-object $ref to the member's type
3ae2d74
Under schema-merging-behavior v1, an allOf member that is a $ref is
embedded in a struct, whatever its type. encoding/json flattens only an
embedded struct, and encodes an embedded field of any other type under a
key named after the type. So when the member is untyped, a primitive,
an array or a map, the value went out as {"Name": "x"} instead of "x",
and decoding the value the spec describes failed, or for a map left it
empty (oapi-codegen#2590).

- An allOf of one such $ref, next to members that add no fields (the
  "decorate a $ref" idiom, with a description or nullable: true), is
  now the member's type, as under v2: `type Wrap = Name`, `One *Name`.
- An untyped member places no constraint and carries no value of its
  own, so it never decides the composition's type: a primitive, array
  or map member next to it is the composition's type, and a
  composition of only untyped members is the first one's type.
  Whenever another member adds fields or brings a type of its own, an
  untyped member keeps its embedded field, so code that sets it still
  compiles, but is tagged json:"-": it used to add an "Untyped" key to
  every object.

A member is classified by the type the generator declares for the
schema it refers to. It is generated in a context of its own, whose
merge frames are copies, so that generating it can't tell a composition
in progress that something referred back to it, and each classification
is remembered in the context: a member that is itself an allOf of a
$ref would otherwise be generated twice per link, and a chain of such
compositions took time exponential in its length. A member whose type
may carry JSON methods the struct inherits (time.Time,
openapi_types.UUID, an x-go-type naming a type or an interface with
methods) is embedded as before, and so is a member whose type would
make a cycle of aliases (`type Root = Arr` with `type Arr = []Root`),
which Go rejects.

servers/strict/responsebody_v1 and the v1 case of
TestStrictServerComposedResponseReceiverCompatibility recorded the old
shape. Those allOf responses are now the untyped member's type, which a
strict response carries in a Body field (oapi-codegen#2514), as under v2 and v3,
and they send the value itself.

The aggregate matrix harness's echo now fills a Body-wrapped strict
envelope, so a shape with an interface-typed body runs at every
position. The matrix's allof_array, allof_default and
allof_unique_items shapes no longer mark v1 broken, since it now
round-trips them. New shapes cover a map member, an untyped member,
two untyped members, an untyped member next to a map, and an untyped
member next to properties. An allOf whose member is an inline array
(allof_recursive_array) is a different v1 bug, and stays marked.

Fixes oapi-codegen#2590

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@mromaszewicz
mromaszewicz force-pushed the fix/v1-allof-non-object-member branch from cdda436 to 3ae2d74 Compare September 29, 2026 03:26
@mromaszewicz
mromaszewicz merged commit a157c2a into oapi-codegen:main Sep 29, 2026
15 checks passed

Uh oh!

There was an error while loading. Please reload this page.

@mromaszewicz
mromaszewicz deleted the fix/v1-allof-non-object-member branch September 29, 2026 03:32
@mromaszewicz mromaszewicz mentioned this pull request Sep 29, 2026
refactor: share the Go-type and lowered-type walks the aggregate code grew #2592
Merged
mromaszewicz added a commit that referenced this pull request Sep 29, 2026
@mromaszewicz @claude
refactor: share the Go-type and lowered-type walks the aggregate code…
83e2818
… grew (#2592)

* refactor: read Go type expressions in one place

Four functions parsed a Go type expression with go/parser, each in its
own file and its own way: goTypeNeedsBodyWrapper and v1GoTypeKind look
at the outermost node, and mentionsTypeName and
qualifyExternalTypeNames walk the identifiers in type position, one
with ast.Inspect and one with a hand-written switch over node kinds.

gotype.go now holds parseGoType, unparen, typeIdents and
isPredeclaredType, and the four use them. Generated output is the same
for every committed spec. Two inputs no spec has are read differently:
v1GoTypeKind sees through parentheses around a type, as
goTypeNeedsBodyWrapper already did; and typeIdents reaches the types in
an interface's type set (~int | Foo), which the hand-written walk did
not, and leaves an array's length alone, which ast.Inspect did not.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* refactor: find the schema a type was lowered from in one place

The strict-server response classification (operations.go) and v1's allOf
member classification (merge_schemas_v1.go) each walked, in their own
words, from a schema to the one its Go type came from: the $ref member a
composition was lowered to, and the type x-go-type-name declares beside
the schema. lowered.go now holds that walk, as loweredToMember and
Schema.namedTypeDef, and the two classifications keep only what differs
between them: how they generate a schema (a fresh context, and an
isolated one with a memo), and what they ask of the type they reach.

v1 used to match a composition's $ref member by the member's name, and
now matches it by the type the member generates as, as the response
classification does. The two differ only for a member whose $ref carries
an x-go-type, which v1 keeps embedded either way.

subschemaLists spells out a schema's allOf, anyOf and oneOf for the four
places that walked all three.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* refactor: remove GenStructFromAllOf, which nothing calls

The exported wrapper had no caller in the repository, and #2591 gave
it a copy of mergeSchemasV1's classification of the members. Only
genStructFromAllOf remains, which mergeSchemasV1 hands the context and
the classification it already has.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* docs: say once why v2 unions nullability, and what v1 does with a sole member

The rationale for unioning "null" across allOf members was written out
twice in mergeOpenapiSchemas, at the type comparison and at the
nullability merge; the type comparison now refers to the latter. The
comment on the defensive clone of the merged extensions narrated
hypothetical future changes; it now says what the clone is for. The
comment on generateAllOfV2's pure-wrapper case said v1 never returns the
referenced type unchanged; since #2591 it does so for a member that
isn't an object.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@greptile-apps greptile-apps[bot] greptile-apps[bot] left review comments

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

bug Something isn't working

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

schema-merging-behavior v1: an allOf of a non-object $ref serializes under the member's type name

Uh oh!

There was an error while loading. Please reload this page.

1 participant

@mromaszewicz
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.