Skip to content

fix(typescript-fetch): fix what the e2e tests find, hold it to strict e2e - #15

Merged
xseman merged 18 commits into
masterfrom
fix/typescript-fetch-e2e
Oct 27, 2026
Merged

xseman merged 18 commits into
masterfrom
fix/typescript-fetch-e2e

Conversation

@xseman

@xseman xseman commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Fixes the typescript-fetch bugs the end-to-end tests found and holds typescript-fetch to the same bar as go-fetch: every scenario it can pass and every generated value case, exactly, with the client compiled tsc --strict.

What was wrong, now fixed

Values (FromJSON / ToJSON). A recursive converter (internal/generator/typescript/convert.go) replaces the per-shape template branches:

  • lists of lists and maps of models or dates, a nested body or result, format: date values (as local days) at any depth;
  • unions: by the discriminator's mapping (implicit for allOf children), by a property every member pins (string or int, one value or several), else by the member the value fits best (bestFit, lists too); a $ref union by its model; an allOf member as an intersection; an anyOf of models as a oneOf;
  • schemas that only name an array, a map or a scalar are type aliases that convert (type Pets = Array<Pet>, type Day = Date);
  • an optional nullable null stays null; keys next to declared properties (additionalProperties) go through; a binary property of a JSON model is a string; __proto__ / constructor keys are read and written as own keys.

Requests. Parameters by style and explode (label, matrix, delimiters, objects in path/header/query, unset properties left out); cookies and API keys in a Cookie header; form fields by their encoding (a list a field or part per item, an object a JSON part, a form with a file as multipart); a date body as a day.

Responses. A 2xx the spec declares without a body (204 beside 200, HEAD) is null, decided by status; any other empty body stays a parse error; a 2xx of another body than the result's throws ResponseError; an any sent as text is text, as an image or octets a file; JSON Lines and server-sent events (3.2 itemSchema) are AsyncIterable (ServerSentEvent<T> for events).

Compiles. Every client of the feature specs and of the GitHub, Kubernetes and Stripe specs builds strict, also with each boolean option flipped (withoutRuntimeChecks, useSingleRequestParameter=false, ...). Name clashes with the runtime (BaseAPI, an operation request) are renamed.

Parser fixes shared by the generators: array params joined by style, server variable defaults in the base path, enum line breaks escaped, /*/* in a description no longer ends its comment, nested container imports, allOf members of a oneOf. go-fetch also reads an any sent as an image as bytes; its output is otherwise unchanged.

Behaviour changes for existing typescript-fetch users

  • a 2xx whose declared body differs from the result's throws ResponseError;
  • a 2xx the spec declares without a body returns null (the type says | null);
  • form arrays go one field per item, not comma-joined;
  • models named like runtime exports get the Model prefix (ApiResponse → ModelApiResponse, as upstream); an operation request becomes requestOperation; a property constructor is _constructor;
  • schema aliases are type aliases instead of empty interfaces.

The e2e harness, stricter

  • tsc --strict; a client with a type error fails its own suite;
  • the runner checks every argument and result against the client's types (TypeScript names, Dates, days at local midnight, enum values), builds union arguments and days as users do, restores read-only properties;
  • generated cases check query and headers too (optional parameters absent);
  • TestTypeScriptOptions compiles every feature spec with each option flipped;
  • an empty body where the spec declares one must fail (fails: true), not read as null;
  • new feature specs and FastAPI shapes from three critic rounds: nested containers, aliases, discriminators without mapping or with int tags, oneOf by value, runtime name clashes, +json bodies, CRLF event streams, binary responses.
Generator Scenarios (pass / fail / skip) Generated values
go-fetch 156 / 0 / 5 655 / 0
typescript-fetch 154 / 0 / 7 (was 100 / 0 / 46) 655 / 0, strict (was lenient, 92 failing)
dart-fetch 10 / 0 / 151 lenient

Optional real-spec run (E2E_SPECS): typescript-fetch GitHub 11,633 / 22, Kubernetes 2,239 / 0, Stripe 2,662 / 6; go-fetch 0 failures on all three.

Left as they are

Integers past 2^53, naive date-times (a Date keeps no missing offset), FastAPI's repeated list headers, a multipart JSON part (FormData sends it as a file named blob), decoded error models. The remaining real-spec cases are union members whose keys tell them apart only partly.

xseman added 16 commits October 26, 2026 18:21
What typescript-fetch got wrong traced back to the parser in places every
generator shares:

- an array parameter joins its items as its style says (csv, ssv, pipes,
  one per value for an exploded form) instead of always "multi";
- the base path takes the defaults of the first server's variables;
- an enum's string literal escapes line breaks (U+2028 and U+2029 too),
  and go-fetch reads them back;
- a description holding "/*/*" no longer ends its doc comment: the two
  replacements run one after the other, as upstream's do;
- a union with an inline-object member is any, not its other members
  (string | object read as string);
- a nested container in a response imports its innermost model, not
  "ArrayCell";
- a form field keeps its free-form flag, and an anyOf with inline
  members says so (x-any-of-inline).

dart-fetch gains the same import and enum escape; go-fetch's output is
unchanged.
…ember

Fixes what the e2e tests and generated clients of the GitHub, Kubernetes
and Stripe specs showed. Every client of the feature specs and of the
three real specs now compiles with tsc --strict.

Values: a recursive converter (convert.go) writes FromJSON and ToJSON:

- lists of lists and maps of models or dates, a nested response or body;
- a $ref to a union by its model; an inline union by the property every
  member pins (pydantic's Literal), else by the member it fits best
  (bestFit: required keys there, most keys declared); scalar unions by a
  date's text; an anyOf of models as a oneOf;
- an optional nullable null stays null; the keys next to the properties
  (additionalProperties) go through, converted by their schema; a binary
  property of a JSON model is a string; keys every object inherits
  (constructor, __proto__) are read and written as own keys;
- a discriminator without mapping maps the allOf children by schema name.

Requests: path, header, query and cookie parameters go by style and
explode (label, matrix, objects, delimiters); cookies and an API key in a
cookie as a Cookie header; form fields by their encoding, a list a field
or part per item, an object a JSON part; a form with a file as multipart.

Responses: a 2xx without a body (a 204 beside a 200, a HEAD) is null; a
2xx of another body than the result's throws ResponseError; a string or
any sent as text is the text; JSON Lines and server-sent events (3.2's
itemSchema) are an AsyncIterable, of ServerSentEvent for events.

Names: a model named like a runtime export (BaseAPI) is prefixed, an
operation named like a BaseAPI member (request) suffixed, a model mapped
twice imported once, an enum value with a line break escaped. A parsed
vendor-extension map is the document's own, shared by every property of
one $ref: the generator writes to a copy.
…lues

typescript-fetch now passes every scenario it can and every generated
value case, as go-fetch does; only dart-fetch stays lenient.

The harness hid gaps: tsc compiled without strict and ignored errors, and
a FromJSON and a ToJSON that both leave a value alone pass a round trip.

- tsc runs strict; a client with a type error fails its own suite, and a
  client whose JavaScript does not parse no longer stops the run;
- the runner checks every argument and result against the client's
  types: TypeScript property names in a model, a Date where the interface
  says; it puts back the read-only properties ToJSON leaves out;
- typescript-fetch compares date-times as instants to the millisecond, a
  Content-Type by its media type; its error model is the body only where
  the spec declares one for the status (`bodies` in the runner input);
- streams are read as the list of their items.

New and wider feature specs cover what a critic found the scenarios
missed: nested containers of models and dates, a discriminator without
mapping, oneOf members told apart by value, runtime name clashes, date
headers, an integer sent as text, __proto__ and constructor properties.

The skips typescript-fetch passes now are gone; seven stay: integers past
2^53, naive date-times, the FastAPI list header and a multipart JSON part
(FormData gives a part a type only as a file).
…embers

- A schema that only names an array, a map or a scalar (`Pets: {type:
  array, items: Pet}`, `Day: {format: date}`) is a type alias that
  converts as its value does, not an empty interface whose FromJSON and
  ToJSON leave a body or a result as it came.
- A oneOf member that is an allOf of models is their intersection
  ("A & B"), converted by each and merged, not its first model: an object
  typed `type: object` over an allOf is typed so too, and a string model
  of format date or date-time says so (IsDate, IsDateTime).
- A discriminator parent writes a value no mapping names as itself, not
  as it came.
A feature spec of an array, a map and a date alias as a body and a result,
and the discriminator parent sent as itself. The TypeScript runner writes
a result with its model's ToJSON before taking it for a plain Date.
Two places a typescript-fetch bug passed the e2e:

- A day (format: date) parsed as UTC midnight is the right day in
  Europe/Bratislava, where the runners run, and the day before west of
  UTC. The runner now requires a decoded day at local midnight, and makes
  a day argument with the client's parseDate, as its users do, not as UTC
  midnight.
- A generated case checked the body only. Its query and headers must now
  hold the required parameters' values and none of the optional ones
  (headers an HTTP client sends itself aside), so an empty `?q=` fails.

Both were proven with a broken template: 759 and 145 cases fail now.
- An inline union as a request body converts as one in a property does:
  the parameter now carries its members to the converter.
- bestFit picks a list member too, by all its items: `Array<SimpleUser>
  | Array<Stargazer>` converts each item.
- A union with an inline object is typed any; it now also stays as it
  came, not converted by its one model member.
From the round-2 harness critic, each shown by a mutation the e2e passed:

- Union members with snake_case names (composition-oneof-inline): with the
  same names in JSON and TypeScript a union body sent unconverted looked
  right. The runner now builds a union argument from the member whose
  JSON names hold the value's keys, and writes a union result with the
  member whose type the value fits; it also reads `T | null | undefined `
  as T.
- An empty header and cookie must still be sent.
- Server-sent events with CRLF line ends, an id that stays across events.
- A generated case is named after its operation.
…d every option

- An object parameter or form field sent its unset optional properties as
  the text "undefined" (`g=a,1,b,undefined`, `h[b]=undefined`, a header,
  an urlencoded field): styleParam, formFields and querystring now leave
  them out.
- A `format: date` request body went out as a UTC date-time: the body
  parameter now carries the date flags, as every other parameter does.
- With useSingleRequestParameter=false, required arguments come first
  (an optional one before a required one does not compile), and the
  method keeps no local variable a parameter could be named like.
- With withoutRuntimeChecks, a model or alias imports the types it names
  and a oneOf model no functions its members do not have.
- TestTypeScriptOptions generates every feature spec with each boolean
  option of typescript-fetch flipped and compiles them strict; two options
  produced clients that did not compile.
- The color of parameters-object-styles and the filter of
  parameters-deepobject gain an optional property the cases leave unset.
- dates gains a request body that is a day.
…, read binary any

- An inline union discriminates by a tag every member pins to values of
  its own, an int Literal or a Literal of several values too: before,
  only one string value counted, and the members, alike by their keys,
  converted as the first (a day sent as a date-time, 422).
- A union model compares its discriminator as text, so an int tag matches
  its mapping (`case "1"` did not compile against `1 | 2`), and a member
  writes and reads its own tag instead of the mapping's text.
- An any result the server sends as an image or as octets is a file, not
  text that loses its bytes (FastAPI's documented binary response).
An any result decoded as text whatever its media type, so the bytes of
FastAPI's documented binary response (`image/png` without a schema) came
back changed. Text media types still decode as text.
…dies

From round 3 of the typescript-fetch gauntlet:

- The FastAPI backend gains unions tagged by an int and by a Literal of
  several values, as a body, a result and a schema of their own, and a
  binary response without a schema.
- request-body-other-media gains a merge-patch+json body answered with
  problem+json: no +json media type was exercised.
- A server-sent event after a named one must carry no name.
- The TypeScript runner checks an enum holds one of its values, and picks
  a union member for an argument by its keys and its tags.
From the final review of the gauntlet fixes:

- A oneOf with a discriminator sets the tag on its member again, the
  member's own value first: a member that does not declare the tag lost
  it both ways once the mapping's text stopped being forced.
- An alias of a list of models as a union member is a list for bestFit;
  another alias (a scalar, a map) is left out, not called on an object
  (`json.map is not a function`).
- A $ref to a union model is typed as that model.
- An int tag of a million or more matches its mapping (1000000, not
  1e+06); a media type is text by its subtype (`xlsx` is no xml text).
An xlsx download (`...spreadsheetml.sheet`) holds "xml" in its media type
and read as text; an any result is text only for text/* or a json, xml,
yaml, csv, javascript or form subtype.
- composition-oneof-scalars gains a union of a list alias and a model and
  a discriminated union whose members do not declare the tag.
- A generated case expects a string parameter as it is (not JSON-escaped)
  and the properties of an optional exploded query object absent.
- The TypeScript runner lets a union's tag stand on its member.
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report

Project coverage is 59.47%. Comparing base (5687415) to head (cc4dcae).

@@           Coverage Diff            @@
##            base    head    +/-     ##
  Coverage  59.50%  59.47%  -0.03%
========================================
  Files         38      38       0
  Lines       4244    4246      +2
========================================
  Hits        2525    2525       0
========================================
 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.05% (122/137) github.com/xseman/openapi-generator/internal/gen/render.go
 73.49% ( 61/ 83) github.com/xseman/openapi-generator/internal/gen/spec.go
 59.41% ( 60/101) 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
 80.83% ( 97/120) github.com/xseman/openapi-generator/internal/generator/golang/base.go
 80.21% (454/566) github.com/xseman/openapi-generator/internal/generator/golang/fetch.go
 58.65% ( 78/133) github.com/xseman/openapi-generator/internal/generator/typescript/base.go
 83.54% (132/158) github.com/xseman/openapi-generator/internal/generator/typescript/convert.go
  4.15% ( 13/313) github.com/xseman/openapi-generator/internal/generator/typescript/fetch.go [-] -0.03%
 86.67% ( 39/ 45) github.com/xseman/openapi-generator/internal/parser/imports.go
 65.79% (125/190) github.com/xseman/openapi-generator/internal/parser/load.go
 72.78% (131/180) github.com/xseman/openapi-generator/internal/parser/model.go
 98.33% ( 59/ 60) github.com/xseman/openapi-generator/internal/parser/naming.go
 90.91% (110/121) github.com/xseman/openapi-generator/internal/parser/normalize.go
 77.65% (205/264) github.com/xseman/openapi-generator/internal/parser/operation.go
 89.86% ( 62/ 69) github.com/xseman/openapi-generator/internal/parser/parameter.go
 28.00% (  7/ 25) github.com/xseman/openapi-generator/internal/parser/parser.go
 82.37% (243/295) github.com/xseman/openapi-generator/internal/parser/property.go
 78.57% ( 66/ 84) github.com/xseman/openapi-generator/internal/parser/propertyorder.go
 75.56% ( 68/ 90) 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.47% [-] -0.03%

Generated at 2026-10-26T19:51:58.677Z by coverage

…body

An empty body read as null for every response broke clients that rely on
it throwing. The xxxRaw method now returns null by status: a 2xx the spec
declares without a body, every 2xx of a HEAD. Any other empty body stays a
JSON parse error, and the body is no longer read twice.
@xseman
xseman merged commit 99cc4e1 into master Oct 27, 2026
3 checks passed
@xseman
xseman deleted the fix/typescript-fetch-e2e branch October 27, 2026 19:38
@xseman xseman mentioned this pull request Oct 27, 2026
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