Skip to content

feat(go-fetch): Go client generator, OpenAPI 3.1 normalization, auth fix - #12

Merged
xseman merged 18 commits into
masterfrom
feat/go-fetch
Oct 26, 2026
Merged

xseman merged 18 commits into
masterfrom
feat/go-fetch

Conversation

@xseman

@xseman xseman commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Adds go-fetch, a Go client generator in the shape of typescript-fetch whose output imports only the standard library, plus the parser and CLI fixes it surfaced. Every commit builds and passes go vet and go test.

Changes

  • go-fetch: the generator, templates, sample and tests. It covers:
    • typed results, error models per status, and another 2xx body returned as a *ResponseError;
    • nullable results and items as pointers, and a required nullable body sent as null;
    • extra keys next to properties;
    • naive date-times (DateTime), and enums of mixed types as any;
    • a form holding a file sent as multipart, a binary JSON property as text;
    • streamed responses (3.2 itemSchema, which FastAPI writes for yield) as iter.Seq2[T, error], for JSON Lines and server-sent events.
  • parser, all generators:
    • operation security is resolved from its scheme and the document, so the TS/Dart auth blocks render;
    • OpenAPI 3.1 is read as upstream's normalizer does: null members, type lists, const, contentMediaType binary, null in an enum;
    • a union with a member that takes null is nullable;
    • parameters that share a camelCase name are kept (id, id2), not merged;
    • a Swagger 2.0 spec in YAML is accepted, and a typeless 2.0 definition is any;
    • a 3.1 document may use 3.2's itemSchema, but query and additionalOperations are still an error.
  • cli: -p a=1,b=2, as upstream does.

typescript-fetch and dart-fetch output changes only for security (now rendered), the enum specs (now equal to upstream) and clashing parameter names (path2 on Kubernetes, as upstream).

Checks

  • make test, make lint (0 issues) and make regress.
  • make e2e (test(e2e): generated clients against a validating mock and a FastAPI backend #13): go-fetch passes 144 of 146 cases against a validating mock and a strict FastAPI backend. The 2 skips are values it cannot represent: integers past int64, and list headers that FastAPI reads differently from the spec.
  • Values generated from the schemas (test(e2e): generated clients against a validating mock and a FastAPI backend #13) pass through the client exactly: 602 from the feature specs and FastAPI, plus 16.5k from GitHub, Kubernetes and Stripe.
  • Real specs: GitHub (1042 files), Kubernetes (887) and Stripe (1540) build and vet with 0 findings. oapi-codegen v2.8.1 and ogen v1.24.0 fail on GitHub.
  • Blind A/B/C reviews against oapi-codegen and ogen on a live server ranked go-fetch first in all 10 rounds.

Known gaps

  • format: uuid is a string.
  • A oneOf parameter is json.RawMessage.
  • A discriminator without oneOf (allOf inheritance) leaves the parent a plain struct.
  • additionalProperties inherited through allOf is dropped.

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.
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report

Project coverage is 61.54%. Comparing base (650156c) to head (a6ff210).

@@           Coverage Diff            @@
##            base    head    +/-     ##
  Coverage  61.51%  61.54%  +0.03%
========================================
  Files         37      37       0
  Lines       3853    3856      +3
========================================
+ Hits        2370    2373      +3
========================================
 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
 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
  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
 65.79% (125/190) 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.91% (110/121) github.com/xseman/openapi-generator/internal/parser/normalize.go [+] +0.23%
 77.48% (203/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
 82.94% (243/293) 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: 61.54% [+] +0.03%

Generated at 2026-10-25T21:15:29.527Z by coverage

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%.
xseman added 11 commits October 25, 2026 20:40
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.
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.
openapi2conv leaves an empty properties map on every schema, so a
definition without a type (Kubernetes' JSON, JSONSchemaPropsOrArray) was
read as an object: go-fetch typed it map[string]any and failed to decode
any other JSON value, every CRD with an enum among them. Only properties
that are there make a typeless schema an object now, as in 3.0.
A format: binary property (3.1 contentMediaType, what pydantic writes for
bytes) was a []byte, so it went out base64 and failed to decode any value
that is not base64. A JSON string carries such bytes as they are: it is a
string now. format: byte stays []byte, base64 on the wire.
Found by schema-driven values through the client:

- An array item that refers to a nullable schema is a pointer ([]*App),
  as a map value already was, and so is a result of a nullable model
  (*App): null became a zero value.
- Another 2xx with an inline object body gets a type of its own
  (SetRecords207Response) and comes back as a *ResponseError: it was
  decoded as the result, its other fields dropped.
- A model named Blob, the parser's name for binary, is a JSON body, not
  an io.Reader.
Only a JSON document was recognised as Swagger 2.0, so a YAML one went to
the OpenAPI 3 loader and failed with "value of openapi must be a non-empty
string". A top-level swagger: "2.x" key marks one in YAML too; it is
converted to JSON for kin's Swagger 2.0 model, and the property order is
read from the source as before.
FastAPI describes a yield endpoint with 3.2's itemSchema in a 3.1
document: the spec failed validation and go-fetch dropped every item.

- The parser accepts 3.2 fields in a 3.1 document, normalizes and marks as
  used what an itemSchema refers to, and records a streamed response's
  media type and item as x-stream and x-stream-item; it keeps no return
  type, so typescript-fetch and dart-fetch are unchanged.
- go-fetch returns an iter.Seq2[T, error] of the items as they arrive:
  JSON values for JSON Lines, a ServerSentEvent[T] per event for
  text/event-stream, T the contentSchema of its data or the text itself.
  An inline item gets a type named <Op>Item.
A required body that may be null (GitHub's gists/update) was a value, so
null went out as {}. It is a pointer now, and nil sends null; an optional
body still sends none for nil.
A oneOf/anyOf whose member is nullable (GitHub's expires_at: a nullable
date or 0) takes null too, but the union was not nullable: go-fetch
dropped an explicit null and typescript-fetch typed it without null.
@xseman
xseman merged commit f2f9cb7 into master Oct 26, 2026
3 checks passed
@xseman
xseman deleted the feat/go-fetch branch October 26, 2026 08:17
@xseman xseman mentioned this pull request Oct 26, 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