Skip to content

fix: valid specs generate unusable params, bodies, and enum constants - #49

Merged
giraffesyo merged 4 commits into
canaryfrom
fix/issues-15-16-17
Aug 5, 2026
Merged

fix: valid specs generate unusable params, bodies, and enum constants#49
giraffesyo merged 4 commits into
canaryfrom
fix/issues-15-16-17

Conversation

@giraffesyo

@giraffesyo giraffesyo commented Aug 5, 2026

Copy link
Copy Markdown
Member

Closes #15, closes #16, closes #17, all filed by @antifuchs against the Mealie spec.

#15: an enum of comparison operators sanitized every value down to the same identifier, so the constants came out as RelationalOperator2 through 7. Punctuation-only values are spelled out now, so "<>" becomes RelationalOperatorNotEqual.

#16: anyOf: [{type: string}, {type: "null"}] is just how 3.1 spells "nullable string", so it collapses to the variant's own type instead of *any. Same for unions whose members are all refinements of one type, which covered the rest of the fields in the reported struct.

#17: multipart bodies were JSON-marshaled and sent as application/json. The declared media type picks the encoding now, and binary properties in a multipart body become a FormFile you can give a filename and content type. Servers that key on Content-Disposition won't treat a nameless part as an upload.

Checked by generating the Mealie client (245 types, 247 operations) and running the three examples against a test server, not just compiling it.

Also in here

Verifying the above kept turning up adjacent bugs that made clients fail to compile or silently lose data. Most predate this branch:

  • recursive schemas (type Node struct { Child Node }) and recursive aliases, neither of which Go allows
  • schemas named like something the generator declares (Client, APIError, ErrNotFound, ListUsersParams)
  • request bodies with no schema, which generated a method with no parameter type
  • allOf dropping additionalProperties entirely, and one off-type extra property failing a whole decode
  • discriminated unions not being comparable, which broke == for every struct holding one
  • form bodies typed as something the encoders reject, and TRACE operations skipped outright

Known gap

#50: when a schema's allOf parent has additionalProperties, Go promotes the parent's marshalers onto the child and they swallow the child's declared fields. Fixing it means replacing the shadow-type trick with field-by-field marshaling, which is its own change.

Checks

golangci-lint run ./... clean, go test -race -shuffle=on ./... green. Every testdata/ spec plus Mealie and nine edge-case specs regenerate, compile, and vet.

Closes #15, #16, #17, all reported against the Mealie OpenAPI 3.1 spec.

An enum of comparison operators sanitized every value to the same
identifier, so the constants came out numbered (RelationalOperator2..7)
with nothing to say which was which. Punctuation-only values are now
spelled out: "<>" becomes RelationalOperatorNotEqual.

A parameter typed `anyOf: [{type: string}, {type: "null"}]` -- how
OpenAPI 3.1 spells "nullable string" -- generated *any, which callers had
no way to construct a value for. Such a union collapses to the variant's
own type, as does one whose members are all refinements of a single type
(a uuid-formatted string or a plain string). Members that are inline
schemas now keep their Go type instead of degrading to any, and a
`type: null` member no longer becomes a variant of its own.

A multipart/form-data body was JSON-marshaled and sent as
application/json. The declared media type now selects the encoding:
multipart is written part by part, url-encoded bodies become form pairs,
and a body in any other media type is sent as the bytes or text it
already is. Binary properties of a multipart body generate a FormFile
carrying a filename and content type, which is what makes a server treat
the part as an upload at all.

Fixed along the way, each of which produced a client that did not
compile or silently lost data:

- Mutually recursive schemas emitted `type A = B; type B = A`. Go
  expands aliases eagerly, so no indirection saves them -- `[]` included.
- A body offered as both JSON and multipart typed its binary properties
  as FormFile while emitting no such type.
- A spec-controlled media type was interpolated into Go source unquoted.
- additionalProperties never reached the wire on a non-JSON body.
- A CRLF in a filename or content type could inject MIME headers.

Generated clients are exercised, not just compiled: the e2e tests run
the client against a server that parses what it sent.
The templates always declare Client, APIError, RetryConfig and friends at
package scope. A schema of the same name was emitted alongside them, and
Go has one package scope, so the client did not compile. Those names are
now reserved before any schema is converted, so such a schema is renamed
the same way any other collision is.

FormFile, added for multipart bodies, joined that set and is reserved
with them.

Reserving surfaced a second bug in the same area: a reference to a schema
converted later fell back to its exported spelling rather than the name
the schema was actually assigned. Two schemas differing only in
punctuation share one spelling, so `foo-bar` and `foo_bar` both became
FooBar and a forward reference to the second silently pointed at the
first -- it compiled, and decoded into the wrong type. Every component's
Go name is now settled up front and referenced from there.
Five bugs, each of which produced a client that did not compile or lost
data the payload carried.

A required property referencing its own type generated `type Node struct
{ Child Node }`. Go allows a type to contain itself only through an
indirection, so such a field is now a pointer -- the tree, threaded
comment, and category-hierarchy shapes every spec of any size has. A
slice or map between the two ends already breaks the recursion and is
left alone.

A request body the spec declares without a schema generated a method
with an untyped parameter, which is a syntax error. It now takes the
bytes or text its media type implies, or any under JSON; a body with no
content at all is no body, not an empty one.

Discriminated unions stopped being comparable when the preserved payload
of an unrecognized variant was stored as json.RawMessage. A union is an
ordinary field of the structs that hold it, so that made every one of
those structs uncomparable too -- consumers could no longer compare them
or use them as map keys. The payload is kept as a string, which also
stops Raw() from handing out an alias of the union's own buffer.

Composed schemas dropped additionalProperties entirely: only plain
objects collected them. They are collected for allOf now, and the wire
names an embedded schema contributes are recognized as declared, so
inherited properties are not also re-collected and emitted twice.

A typed additionalProperties failed the whole decode when one undeclared
property did not match the declared value type, losing the response
along with it. Such a property is skipped instead, matching how an
unrecognized discriminator is already handled.

Composed schemas whose embedded schema also collects undeclared
properties keep the previous behavior, which is not yet correct: the
embedded marshalers are promoted onto the outer struct and swallow its
declared fields. Fixing that means replacing the shadow-type trick with
field-by-field marshaling; tracked separately.
…ncode

The identifiers reserved against schema names covered only the ones the
templates always declare. The templates also build names at render time
-- <Op>Params for an operation's parameters and <ErrorType>Response for
an error body -- which a static list cannot hold. Those are reserved as
the spec is walked, before schema names are assigned, so a schema called
ListUsersParams no longer redeclares the struct of that name.

The ten Err* sentinels errors.go always declares were missing from the
list outright. The test could not have caught either, because it iterated
the list it was checking; it now parses the generated files and asserts
every exported package-level name is reserved, which fails when a
template gains a declaration nothing reserved.

A form or multipart body whose schema is not an object generated a method
taking that scalar, which every call then failed to encode, since the
encoders build parts out of an object's properties. Such a body takes a
map instead. A trace operation was silently skipped: the walk that is
meant to be the single source of truth for a path item omitted it.

A byte slice in a multipart body is now sent as the base64 text
OpenAPI's format: byte calls for, rather than as a file part. Files
arrive as FormFile, and the schemas a multipart body composes with allOf
are marked alongside it, so a binary property inherited through
composition is still generated as one.

Breaking an alias cycle keeps the shape the alias carried: a self
referencing array is `= []any` rather than `= any`.
@giraffesyo
giraffesyo merged commit d4c29cc into canary Aug 5, 2026
7 checks passed
@giraffesyo
giraffesyo deleted the fix/issues-15-16-17 branch August 5, 2026 18:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant