Skip to content

fix: generator gaps found by the e2e tests - #14

Closed
xseman wants to merge 14 commits into
masterfrom
fix/e2e-gaps
Closed

xseman wants to merge 14 commits into
masterfrom
fix/e2e-gaps

Conversation

@xseman

@xseman xseman commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #13 (e2e), which is stacked on #12 (go-fetch). Until they are merged, this diff also shows their commits. The changes of this PR are the last five commits.

Fixes the generator bugs that the FastAPI e2e backend from #13 found, and adds the cases that found them. After this PR, every go-fetch case passes except two that it cannot pass.

Fixes

  • parser: parameters that share a name once camelCased are kept. For example, item_id in the path and itemId in a header used to end up as one parameter, and one of the two values was never sent. The second one is now numbered (itemId2), as upstream does. This affects every generator: typescript-fetch on Kubernetes now gets path/path2, which matches upstream.
  • go-fetch: naive date-times. A date-time with no offset (which FastAPI sends for a naive datetime) could not be decoded or sent. The new DateTime{time.Time; Naive bool} round-trips both forms.
  • go-fetch: enums of mixed JSON types. pydantic's Literal["auto", 0] was a Go string, so the number could not be sent or decoded. It is now any.
  • parser: null among enum values makes the schema nullable. pydantic writes Literal["x", None] this way. This also fixes typescript-fetch, which used to drop an explicit null of such an enum.
  • go-fetch: such an enum keeps its type. A nullable enum (enum: [a, null]) is still a typed enum with its constants, not any.
  • go-fetch: a form that holds a file goes as multipart. FastAPI declares a form model that contains an UploadFile as application/x-www-form-urlencoded. go-fetch sent the file as its printed Go struct. Only multipart can carry a file, so such a form now goes as multipart, as upstream's Go client does.

New e2e cases (FastAPI backend)

  • Numbers past int64 and exponent floats. Floats such as 5e-324 and 1e+308 pass everywhere. Integers past int64 are a skip for go-fetch (int) and for typescript-fetch (precision is lost past 2^53).
  • List headers. With one item each, they pass. With two items or more, they are a skip for go-fetch and typescript-fetch. Both send one comma-joined line, as the spec's simple style says, but FastAPI reads only repeated lines. This is the server deviating from the spec, so the clients stay as they are.
  • A form model with files. It now passes for go-fetch. typescript-fetch is still a skip, because it sends the form as URLSearchParams holding a Blob.

Status (FastAPI scenario)

Generator Pass Fail Skip
go-fetch 85 0 2
typescript-fetch 64 0 23
dart-fetch 0 0 87 (does not compile)

The typescript-fetch and dart-fetch gaps are still recorded as skips with their reasons. They are not fixed here.

Checks

  • make test and make lint (0 issues) pass, and so does make e2e.
  • make regress: only go-fetch output changes. typescript-fetch and dart-fetch output for the feature specs is unchanged, except Kubernetes params in TS (path2).
  • The go-fetch clients of GitHub, Kubernetes and Stripe build and pass vet with 0 findings.

xseman added 14 commits October 25, 2026 15:31
The parser recorded only a security scheme's name and scopes, so the
templates never saw its type or key location and rendered no auth at all.
Each operation's auth methods now carry the scheme from components, an
operation without its own security takes the document's, and
"security: []" opts out. Scopes carry -last, as the mustache engine has
no {{^-last}}, so typescript-fetch lists them without a trailing comma.
The auth blocks of typescript-fetch and dart-fetch, rendered for the
first time, are indented as the code around them, and dart-fetch sends
an API key that lives in a cookie.
An array model built its items from the resolved schema, so `items: $ref`
lost the name of the model it refers to. The items are now built from the
reference, as a property's are. typescript-fetch and dart-fetch output is
unchanged for $ref items of objects, enums, aliases and arrays.
…fetch

go-fetch writes a Go client that imports only the standard library: an
API type per tag with a typed, a Raw and a Request method per operation,
a Params struct per operation, and models for structs, enums, aliases and
oneOf/anyOf unions. Optional and nullable values are told apart (`*T`,
`Opt[T]`), errors are a *ResponseError with the declared model decoded
when the body has its required properties, auth comes from the
Configuration, and urlencoded and multipart bodies follow the OpenAPI
encoding defaults (form, exploded; deepObject key by key). Inline objects
and unions get types named after their owner, union As methods check a
variant's required and pinned properties, and a property every variant
pins to its own value is the union's discriminator.

The parser hands generators what go-fetch reads and the TypeScript and
Dart templates ignore: the properties of inline objects (Vars), the
members of inline unions (ComposedSchemas), a property's $ref, and the
encoding of form fields. Tags are post-processed in sorted order, so
names given across operations are stable. Generated files are formatted
with go/format after unused imports are dropped.

Sample in samples/go-fetch; options and type mapping in
templates/go-fetch/README.md.
A 3.1 spec (FastAPI and pydantic write one) spells nullable as a null
member of anyOf/oneOf or a type list with null, a literal as const, and a
file as contentMediaType: application/octet-stream. The parser knew none
of them: every such property typed as a union with null or any, and a
file went out as a text field. A normalize step after loading ports the
upstream OpenAPINormalizer defaults: SIMPLIFY_ONEOF_ANYOF (a null member
makes the schema nullable; one member left is the schema, a $ref as
`allOf: [$ref]`), SIMPLIFY_ONEOF_ANYOF_ENUM (a union of enums is one
enum) and the NORMALIZE_31SPEC parts above.

Also as upstream: a wrapper of an enum model (`allOf: [$ref]`, nullable)
is typed as that model, not as a copy of its values (typescript-fetch
`Status | null`, dart-fetch `Status.fromJson`), and a nullable parameter
or form field is nullable (`q?: string | null`).
`-p packageName=petshop,withGoMod=true` set packageName to the whole
string. One -p may now hold several key=value pairs, as the upstream CLI
takes them; a piece without "=" belongs to the value before it, so a
value with a comma (`npmDescription=cats, dogs`) still works.
Found by driving a client generated from a FastAPI backend with every
common shape against the live server:

- A model with properties and additionalProperties keeps the other keys
  in an AdditionalProperties map, read and written with the fields.
- A 2xx whose body is of another type than the result (a 202 Problem next
  to a 201 Order) is a *ResponseError with that body in Model; the typed
  method used to return an empty result without an error.
- A nullable result is a pointer, nil for null.
- An any result is the text when the response is not JSON (FastAPI writes
  an empty JSON schema next to application/x-ndjson).
- A parameter that refers to an enum model has that type; the parser
  hands parameters their $ref.
Feature specs for what the go-fetch tests left out: objects and arrays in
every path, query, header and cookie style of the OpenAPI serialization
table; text/plain, octet-stream and multipart object bodies with the
Configuration options; the text of scalar query parameters. Unit tests
for alias models, nullable items, bool and float enums, union variants,
FooRaw naming and an operation under two tags.

Generator coverage (with the internal/gen tests) 92% -> 96%, the
generated runtime under the feature tests 79% -> 92%.
`make e2e` checks what a generated client puts on the wire and how it
decodes the answer, for go-fetch, typescript-fetch and dart-fetch alike.
The cases are language-neutral scenarios (testdata/e2e/scenarios) in the
spec's own names and wire values:

- a mock layer: a server built on kin-openapi validates each request and
  each scenario response against the feature spec and records the request,
  so a case checks the exact path, query, headers, cookies and body;
- a backend layer: a strict FastAPI app (pydantic, uv-locked) whose served
  OpenAPI 3.1 spec is snapshotted, called through a client generated from
  it, echoing what it parsed.

A runner per generator turns the scenarios into calls of its client the way
its users write them (Go by reflection, TypeScript from the compiled
sources, Dart with mirrors) and prints one JSON line per case. A gap of a
generator is a skip with its reason on the case; a skip that starts
passing fails. go-fetch passes every case; typescript-fetch skips 29 and
dart-fetch 103, mostly clients that do not compile.

The cookie array of parameters-object-styles is explode: false: the OpenAPI
serialization table defines no exploded form for a cookie.
Six rounds of looking for realistic FastAPI and pydantic shapes a
generated client gets wrong on the wire, each added to the backend with
calls that show it (61 -> 81 cases):

- discriminated unions as the body, a nullable field, map values and
  list items, behind a camelCase key, and a callable discriminator;
- naive and aware date-times, times with offsets, negative durations,
  dates in lists, maps and query arrays; runners now run in a non-UTC
  zone (TZ=Europe/Bratislava);
- required nullables and null against a non-null default, with the keys
  the client sent echoed back;
- a typed form model; multipart labels echo joined by "|" so one part
  "x,y" no longer passes for two;
- parameters whose names collide once camelCased, under a router prefix
  and a shared dependency, and ones named like a client's locals;
- enums and Literals of mixed JSON types and with null.

The gaps they found are skips with their reasons: go-fetch cannot hold a
naive date-time or an enum without a type; typescript-fetch sends a date
query array as Date.toString(), a naive date-time in UTC, an explicit
null of an optional nullable as absent, and does not compile a union
form field; the parser merges parameters of one camelCase name.
Parameters were deduplicated by their camelCase name, so two different
parameters became one and the other was never sent: a path item_id and a
header Item-Id, a query sort_by and sortBy, a query and a form field, or
Kubernetes' proxy path parameter and its path query parameter. A
parameter now replaces only one of the same location and wire name (an
operation's over its path item's), and a second parameter of one name is
renamed itemId2, as upstream's ensureUniqueParams does. typescript-fetch
gets path and path2 on those Kubernetes operations, as the upstream
generator does.

The e2e runners number a repeated identifier the same way, and the FastAPI
name-clash cases now pass for go-fetch and typescript-fetch.
Found by the e2e FastAPI backend:

- A date-time is a DateTime, a time.Time that also reads a time without an
  offset, as FastAPI sends a naive datetime (a database column without a
  zone). Such a time stays Naive and goes back without an offset; before,
  one such value failed the whole call.
- An enum whose values are of more than one JSON type (pydantic's
  Literal["auto", 0, 1], an Enum of 0 and "always") is any: no Go
  constant type holds them, and a string type sent 0 as "0".
- A null among an enum's values (Literal["x", None]) makes the schema
  nullable, so the field can hold and send null.
A form declared application/x-www-form-urlencoded with a binary field (FastAPI
writes a form model with an UploadFile so) sent the File as its fmt text. Only
multipart carries a file, so such a form now goes as multipart, as upstream's
Go client does.
EnumBaseType counted null as a JSON type of its own, so a nullable enum
(enum: [a, null]) became any and lost its constants. null now only makes it
nullable; values of two JSON types (1 and true) are any, as a string mixed
with another already was.
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report

Project coverage is 59.18%. Commit d25233b.

Note

No cached baseline for go. Per-file deltas and the diff table will appear once the target branch has coverage cached. Push to the base branch or trigger the workflow manually to seed the cache.

 30.63% ( 34/111) github.com/xseman/openapi-generator/cmd/openapi-generator/main.go
  0.00% (  0/  3) github.com/xseman/openapi-generator/internal/config/config.go
  0.00% (  0/ 68) github.com/xseman/openapi-generator/internal/gen/dart.go
 69.23% ( 54/ 78) github.com/xseman/openapi-generator/internal/gen/gen.go
 43.48% ( 10/ 23) github.com/xseman/openapi-generator/internal/gen/gofetch.go
 97.78% ( 44/ 45) github.com/xseman/openapi-generator/internal/gen/helpers.go
 77.50% ( 31/ 40) github.com/xseman/openapi-generator/internal/gen/index.go
 82.35% ( 14/ 17) github.com/xseman/openapi-generator/internal/gen/metadata.go
 89.36% (126/141) github.com/xseman/openapi-generator/internal/gen/render.go
 73.49% ( 61/ 83) github.com/xseman/openapi-generator/internal/gen/spec.go
 58.88% ( 63/107) github.com/xseman/openapi-generator/internal/gen/typescript.go
100.00% (  5/  5) github.com/xseman/openapi-generator/internal/gen/validate.go
  0.00% (  0/107) github.com/xseman/openapi-generator/internal/generator/dart/base.go
  0.00% (  0/155) github.com/xseman/openapi-generator/internal/generator/dart/fetch.go
  0.00% (  0/  1) github.com/xseman/openapi-generator/internal/generator/generator.go
 79.17% ( 95/120) github.com/xseman/openapi-generator/internal/generator/golang/base.go
 78.54% (421/536) github.com/xseman/openapi-generator/internal/generator/golang/fetch.go
 58.65% ( 78/133) github.com/xseman/openapi-generator/internal/generator/typescript/base.go
  0.00% (  0/ 91) github.com/xseman/openapi-generator/internal/generator/typescript/fetch.go
 86.96% ( 40/ 46) github.com/xseman/openapi-generator/internal/parser/imports.go
 55.62% ( 99/178) github.com/xseman/openapi-generator/internal/parser/load.go
 75.00% (129/172) github.com/xseman/openapi-generator/internal/parser/model.go
 98.33% ( 59/ 60) github.com/xseman/openapi-generator/internal/parser/naming.go
 90.43% (104/115) github.com/xseman/openapi-generator/internal/parser/normalize.go
 64.12% (168/262) github.com/xseman/openapi-generator/internal/parser/operation.go
 89.06% ( 57/ 64) github.com/xseman/openapi-generator/internal/parser/parameter.go
  4.76% (  1/ 21) github.com/xseman/openapi-generator/internal/parser/parser.go
 80.89% (237/293) github.com/xseman/openapi-generator/internal/parser/property.go
 78.57% ( 66/ 84) github.com/xseman/openapi-generator/internal/parser/propertyorder.go
 63.64% ( 49/ 77) github.com/xseman/openapi-generator/internal/parser/response.go
  0.00% (  0/ 56) github.com/xseman/openapi-generator/internal/parser/security.go
 43.59% ( 17/ 39) github.com/xseman/openapi-generator/internal/parser/typename.go
 65.22% ( 15/ 23) github.com/xseman/openapi-generator/internal/parser/util.go
  0.00% (  0/ 17) github.com/xseman/openapi-generator/internal/template/embedded.go
 95.00% ( 38/ 40) github.com/xseman/openapi-generator/internal/template/gofmt.go
 13.55% ( 34/251) github.com/xseman/openapi-generator/internal/template/mustache.go
 72.93% ( 97/133) github.com/xseman/openapi-generator/internal/update/update.go

Go Coverage: 59.18%

Generated at 2026-10-25T19:35:59.373Z by coverage

@xseman

xseman commented Oct 25, 2026

Copy link
Copy Markdown
Owner Author

Moved into the stack: the generator fixes are now on #12 (feat/go-fetch), the e2e changes on #13 (test/e2e).

@xseman xseman closed this Oct 25, 2026
@xseman
xseman deleted the fix/e2e-gaps branch October 25, 2026 20:02
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.

1 participant