A Go code generator that produces feature-rich HTTP client packages from OpenAPI 3.1 specifications.
Given any OpenAPI 3.1 (or 3.0) spec, it outputs a complete, idiomatic Go client package with zero external dependencies.
- Typed models — structs, enums, type aliases, and union types (allOf/oneOf/anyOf) with JSON marshaling
- Client methods — per-operation methods with
context.Context, typed parameters, and typed responses - Webhooks and callbacks: typed payloads and a dispatcher for the requests the API sends you
- Server URLs:
DefaultBaseURLfrom the spec, with a builder for templated servers - Response headers: status and headers captured through the context, with declared headers parsed per operation
- Authentication —
AuthProviderinterface with built-in Bearer, API key, and Basic auth - Error handling —
APIErrorwith sentinel errors (errors.Is), typed error wrappers with parsed response bodies (errors.As), readable messages viax-ms-primary-error-message - Pagination: auto-detected cursor, offset, and page pagination with a generic
PageIterator[T] - Retries: configurable exponential backoff with jitter, honoring
Retry-Afterin both forms and declining a wait pastMaxDelay - Middleware — composable request/response middleware chain
- OpenAPI 3.1: JSON Schema 2020-12, nullable type arrays,
$refresolution (what is not generated)
Add as a tool dependency to your project (recommended):
go get -tool github.com/parallelworks/openapi-client-generator@latestThis pins the generator version in your go.mod so every team member uses the same version.
Or install globally:
go install github.com/parallelworks/openapi-client-generator@latestgo tool openapi-client-generator generate --spec petstore.yaml --out ./gen/petstoreIf installed globally via go install:
openapi-client-generator generate --spec petstore.yaml --out ./gen/petstore| Flag | Short | Description |
|---|---|---|
--spec |
-s |
Path to OpenAPI spec file (required) |
--out |
-o |
Output directory for generated code (required) |
--package |
-p |
Go package name (default: derived from output dir) |
--user-agent |
Default User-Agent for generated clients (default openapi-client-generator/1.0) |
|
--allow-remote-refs |
Allow fetching remote $ref targets |
For a Petstore spec, the generator produces:
gen/petstore/
├── auth.go # AuthProvider interface, BearerAuth, APIKeyAuth, BasicAuth
├── client.go # Client struct, NewClient(), do() with retry + middleware
├── errors.go # APIError, sentinel errors, typed ErrorResponse wrappers
├── helpers.go # URL building, query param encoding
├── middleware.go # Middleware type, WithMiddleware()
├── operations.go # ListPets(), CreatePet(), GetPetByID(), DeletePet()
├── options.go # WithHTTPClient(), WithUserAgent()
├── pagination.go # PageIterator[T], ListPetsIter()
├── retry.go # RetryConfig, WithRetry(), WithDefaultRetry()
└── types.go # Pet, PetStatus, PetList, CreatePetRequest, Error
package main
import (
"context"
"errors"
"fmt"
"log"
"example.com/gen/petstore"
)
func main() {
client := petstore.NewClient(
"https://petstore.example.com/v1",
petstore.WithAuth(&petstore.BearerAuth{Token: "my-token"}),
petstore.WithDefaultRetry(),
)
ctx := context.Background()
// List pets with pagination
iter := client.ListPetsIter(ctx)
err := iter.ForEach(func(pet petstore.Pet) error {
fmt.Printf("Pet: %s (ID: %d)\n", pet.Name, pet.ID)
return nil
})
if err != nil {
log.Fatal(err)
}
// Create a pet
err = client.CreatePet(ctx, petstore.CreatePetRequest{Name: "Buddy"})
if err != nil {
log.Fatal(err)
}
// Get a specific pet — check for a known status code
pet, err := client.GetPetByID(ctx, 123)
if errors.Is(err, petstore.ErrNotFound) {
log.Println("Pet not found")
return
}
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found: %s\n", pet.Name)
// Extract the parsed error body using the typed error wrapper
_, err = client.GetPetByID(ctx, 999)
var errResp *petstore.ErrorResponse
if errors.As(err, &errResp) {
fmt.Printf("Error %d: %s\n", errResp.Detail.Code, errResp.Detail.Message)
}
}When the spec declares a server, the generated package states it, so the URL does not have to be copied into the code:
client := petstore.NewClient(petstore.DefaultBaseURL)DefaultBaseURL is the first server the spec lists, with every template variable
at its default. A templated server also gets a builder, one parameter per
variable in the order the URL uses them, where an empty argument takes that
variable's default:
servers:
- url: https://{region}.api.example.com/{basePath}
variables:
region: { default: us-east-1, enum: [us-east-1, eu-west-1] }
basePath: { default: v2 }client := petstore.NewClient(petstore.ServerURL("eu-west-1", ""))
// https://eu-west-1.api.example.com/v2A relative server URL (/api/v3) gets neither, since it resolves against
wherever the spec is served and the generated package cannot know that host.
A method returns the decoded body, so what a response says outside its body is read through a capture on the context:
ctx, meta := petstore.WithResponseCapture(ctx)
pet, err := client.CreatePet(ctx, newPet)
meta.StatusCode // 201
meta.Header.Get("X-Trace-Id") // any header, declared or notHeaders the spec declares are also available parsed, per operation:
h := meta.CreatePetHeaders()
h.Location // string
h.XRateLimitRemaining // *int64, nil when absentA header value arrives as text, so only the kinds text parses into unambiguously are typed: integers, numbers, and booleans, each a pointer so an absent header is not a zero that reads as a real value. Everything else, HTTP dates and lists included, stays the raw string. Error responses are captured too, which is where a rate limit usually arrives. Calls made with the returned context each overwrite the meta, so give one capture to one call.
A parameter whose schema is a union with a genuine choice names a type, so the
values it accepts are visible in Go rather than hidden behind any:
- name: either
in: query
schema:
anyOf: [{ type: string }, { type: integer }]either := petstore.ListItemsEither{Value: 42}
client.ListItems(ctx, petstore.ListItemsParams{Either: &either})
// ?either=42The value the union carries is what goes on the wire, under the parameter's own style, so a list variant still explodes and a scalar still goes out as itself. A union carrying nothing sends no parameter at all. One shape is one type, so two parameters declaring the same union share it.
anyOf: [string, null] is not a choice of that kind and still collapses to
*string, as do variants that refine a single Go type. Objects written inline in
a parameter are named the same way, and keep encoding under their style.
Error() on a typed error wrapper renders the property that carries the
human-readable message, falling back to the raw body when it finds none.
A schema names that property in one of two ways. Marking it is exact, either with
the vendor-neutral x-error-message or with Kiota's x-ms-primary-error-message:
components:
schemas:
ErrorResponse:
type: object
properties:
code:
type: integer
message:
type: string
x-error-message: trueError() then renders that property instead of the body:
API error 404 Not Found: Pet not found
The marked property has to be a string, and the first one a schema marks is the
one used. When it is empty, or the body does not parse, the output falls back to
the raw body, so a message never disappears. Detail still holds the whole
parsed body either way.
A schema that marks nothing is read by the names error bodies conventionally use,
most specific first: message, detail, error_description, title, error.
A body carrying several is tried in that order at runtime, so an RFC 7807
response renders detail when it has one and title when it does not. A body
with no such property, or with one holding something other than text, keeps the
raw output.
A spec's inbound half, webhooks and an operation's callbacks, describes
requests the API sends to you. The generated package names their payload types
and decodes them:
// one webhook, its declared type, no assertion
pet, err := petstore.ParsePetCreatedWebhook(body)
// or dispatch on the name your router saw
payload, err := petstore.ParseWebhook(name, body)
switch p := payload.(type) {
case petstore.Pet:
...
}WebhookNames lists what the spec declares, and a name outside it is an error
rather than a nil payload. Callbacks work the same way through ParseCallback
and CallbackNames, keyed as operation.callback, since a callback arrives at a
URL you registered rather than under a name of its own.
Receiving is yours: the generator produces no HTTP handler and does not verify signatures, which are vendor specific and not described by a spec. A webhook with no JSON body to decode gets no parse function, and says so at generation time.
A oneOf/anyOf with a discriminator generates a wrapper whose Value holds
the decoded variant. A discriminator value the client does not know is not an
error: Value stays nil, the original JSON is kept, and re-marshaling returns it
unchanged. Adding a variant server-side therefore stays backward compatible, and
one unrecognized element does not fail the payload it appears in.
for _, shape := range shapes {
if shape.IsUnknownVariant() {
log.Printf("skipping unsupported shape %q", shape.UnknownDiscriminator())
continue // shape.Raw() still holds the original JSON
}
switch v := shape.Value.(type) {
case petstore.Circle:
...
}
}When every variant carries the same properties, the wrapper also exposes them, so the fields all variants share are readable without a type switch that has to be revisited whenever a variant is added:
for _, pet := range pets {
if base := pet.Base(); base != nil {
fmt.Println(base.ID, base.Name)
}
}Base() returns nil for an unknown variant. Variants that compose a shared schema
through allOf name it directly, and there has to be exactly one such schema.
Variants that inline the same properties instead, which is all some producers
emit, get a <Union>Base struct synthesized from the properties every variant
declares identically: same name, same type, same required-ness. That type is
derived from the variants rather than declared by the spec, so it changes when
they do. The discriminator
is left out, since it is how the variants differ and a spec that spells the base
out keeps it out of the shared schema too. The result is a copy, so writing to it
does not change the variant the union holds.
A payload that carries no discriminator property at all is still an error — there
is nothing to identify it by — as is a union without a discriminator when no
variant matches. When the schema declares a discriminator but no mapping, the
variant's schema name is used as the discriminator value, per the OpenAPI spec.
Constructs the generator reads and does not act on. Each one warns at generation time rather than passing silently:
| Construct | Behavior |
|---|---|
prefixItems |
The array stays a slice of one element type. A tuple has no Go shape a slice can hold. |
dependentSchemas |
Not enforced. A property whose shape depends on another is a validation rule, not a type. |
patternProperties with several patterns |
The map takes an any value type, since the patterns disagree about what a key holds. One pattern types the map. |
links |
Read and not used. Following a link is a decision for the caller, not a generated method. |
mutualTLS security scheme |
No auth provider. The certificate is configured on the http.Client. |
MIT — Copyright (c) 2026 Parallel Works