@bazo/openapi-client-generator
v3.1.80
Published
Generate type-safe API clients from an OpenAPI / Swagger JSON spec. One CLI, seven output targets:
Readme
@bazo/openapi-client-generator
Generate type-safe API clients from an OpenAPI / Swagger JSON spec. One CLI, seven output targets:
| Mode | Output | Stack |
|------|--------|-------|
| full (default) | .ts | React Query hooks + Wretch HTTP client + Zod validation |
| playwright | .ts | Class wrapping Playwright's APIRequestContext for E2E tests + Zod validation |
| backend | .ts | Server-side class over the platform fetch + Zod validation — Node ≥ 18, Bun, Deno; no React Query, no React |
| swift | .swift | Codable structs + async/await client (Foundation only) |
| kotlin | .kt | @Serializable data classes + Ktor-based suspend client |
| php | directory of .php | PSR-4 classes + backed enums + Symfony HttpClient/Serializer client |
| zod | <name>.ts | Only the component schemas as Zod 4 schemas plus z.infer types, in dependency order; no client. --schemas narrows it to chosen schemas and what they reference |
| php-legacy | README.md + src/**.php | PHP 7.3/7.4 client: no Composer, no dependencies, ext-curl transport, one class per file, exceptions, OAuth2 client_credentials authenticator |
| go | directory of .go | Structs + typed enums/unions + net/http client with typed per-operation errors (stdlib only) |
The TypeScript modes emit Zod 4 validators (z.email(), z.iso.datetime(), z.int(), z.strictObject(), getter-based recursion), so they need zod >= 4.
Schemas are normalized, sorted by dependency, and emitted with full types; unused declarations are stripped from TypeScript output automatically.
Input may be OpenAPI 3.0, 3.1, 3.2 or Swagger 2.0 — a 2.0 document is lifted to 3.0 first. From 3.2, the QUERY method and additionalOperations (any other method token) are generated as body-carrying calls in every mode, and an in: querystring parameter with an object schema is expanded into ordinary query parameters. Recursive schemas (a schema that refers to itself, or a cycle through several) are supported in every mode. The test suite generates every mode from Forgejo's published API (src/__tests__/fixtures/forgejo.json) as a real-world gate.
Installation
npm install -D @bazo/openapi-client-generator
# or: bun add -d @bazo/openapi-client-generatorYou can also run it without installing:
npx @bazo/openapi-client-generator api.json src/api -n MyClientUsage
openapi-client-generator <spec.json> <output-dir> -n <ClientName> [-m <mode>]| Option | Description |
|--------|-------------|
| <spec.json> | Path to the OpenAPI/Swagger spec, JSON or YAML (positional, required) |
| <output-dir> | Directory to write the generated file into (positional, required) |
| -n, --name | Client/class name — must be a valid identifier (required) |
| -m, --mode | full | playwright | backend | swift | kotlin | php | php-legacy | go | zod (default: full) |
| --namespace | php, php-legacy: root PHP namespace (default: --name). swift: wrap the whole client in enum <ns> so no generated type lands at module scope |
| --php-version | Target PHP version. php mode: 8.0–8.5 (default: 8.1). php-legacy mode: 7.3 or 7.4 (default: 7.3; 7.4 only adds typed properties) |
| --token-scope | php-legacy only: the scope Auth\ClientCredentials requests. Normally taken from the spec's OAuth2 clientCredentials security scheme (its tokenUrl and first scope); without one, the authenticator is generated for the operation whose path ends in /token and uses this scope (none if omitted). An explicit value wins over the scheme |
| --schemas | zod mode only: comma-separated component schema names; the output holds those plus every schema they reference (default: all). Unknown names fail. Ignored with a warning in other modes |
| --package | Go package name, go mode only (default: --name lowercased) |
| --lenient-enums | go mode only: decode enum values the spec does not declare instead of failing the response (the raw value is kept and round-trips; Valid() still reports false). Default: strict |
| --operations | Generate only these operations: comma-separated operationIds (the spec's own, or the one the generator derives for an operation without one) or "METHOD /path". Repeatable. Components no selected operation reaches are dropped, in every mode. An unknown selector is an error |
| --overlay | Apply an OpenAPI Overlay 1.0 file (JSON or YAML) to the spec before generating. Repeatable; applied in order |
| -v, --version | Print version |
| -h, --help | Print help |
The output file is named after --name with the extension chosen by the mode (MyClient.ts, ApiClient.swift, ApiClient.kt). The php mode instead writes a directory named after --name containing one PSR-4 file per class, the php-legacy mode writes README.md and src/ straight into the output directory, and the go mode a directory named after --name holding one Go package.
# React Query client
openapi-client-generator api.json src/api -n MyClient
# Playwright E2E client
openapi-client-generator api.json tests/api -n TestApi -m playwright
# Server-side (Node/Bun/Deno) client — BFFs, services, SDK packages
openapi-client-generator api.json src/api -n ApiClient -m backend
# Swift client
openapi-client-generator api.json Sources/Api -n ApiClient -m swift
# Kotlin client
openapi-client-generator api.json src/main/kotlin -n ApiClient -m kotlin
# PHP client
openapi-client-generator api.json src/Api -n ApiClient -m php --namespace "App\\Api" --php-version 8.2
# Go client
openapi-client-generator api.json internal -n PetStore -m go --package petstoreVendor specs: --operations and --overlay
A vendor API often has hundreds of endpoints of which you call five, and a spec with gaps you cannot fix upstream. Generate only what you use, and patch the spec with an overlay instead of editing a copy of it:
openapi-client-generator brevo.yaml src/brevo -n BrevoClient -m backend \
--operations sendTransacSms,getAccount,"DELETE /contacts/{identifier}" \
--overlay brevo.overlay.yaml# brevo.overlay.yaml — OpenAPI Overlay 1.0: JSONPath (RFC 9535) targets, applied in order
overlay: 1.0.0
info: { title: Brevo spec fixes, version: 1.0.0 }
actions:
- description: Statuses the spec does not list (401, 403, 5xx) keep their error body
target: $.paths.*.*.responses
update:
default: { $ref: "#/components/responses/errorModel" }
- description: The error `code` enum is closed; Brevo adds codes without notice
target: $.components.schemas.errorModel.properties.code.enum
remove: trueAn overlay applies to the document as written — before a Swagger 2.0 spec is converted (so it
targets definitions, not components) and before $refs are resolved (so it can add or replace
them). update merges into each matched object (nested objects merge, anything else — arrays
included — is replaced) or appends to a matched array; remove: true deletes the match. An action
that matches nothing is reported as a warning: it is usually a typo, or a vendor spec that moved.
A handy package.json script:
{
"scripts": {
"gen:api": "openapi-client-generator ./openapi.json ./src/api -n MyClient"
}
}Generated client examples
full — React Query
Peer dependencies: zod, wretch, @tanstack/react-query.
import { QueryClient } from "@tanstack/react-query";
import { MyClient } from "./api/MyClient";
const queryClient = new QueryClient();
const api = new MyClient("https://api.example.com", queryClient);
function TaskList() {
const { data, isLoading } = api.listTasks.useQuery({ page: 1, limit: 10 });
const create = api.createTask.useMutation();
// ...
}Constructor: new MyClient(baseUrl, queryClient, options?, onHttpConfigure?). Each operation exposes query/mutation (raw) and useQuery/useMutation (hooks); responses are typed as ApiResponse<T> and validated with Zod — every documented body strictly, successes and errors (exact, 4XX-style range or default) alike; a body that breaks its schema throws ResponseValidationError carrying the status. The Suspense fetch/queryOptions path throws ApiStatusError for a documented error status whose body validates (data/code from the exact → range → default lookup) and ResponseValidationError for one whose body does not.
playwright — E2E testing
Peer dependencies: zod, @playwright/test.
import { test, expect } from "@playwright/test";
import { TestApi } from "./api/TestApi";
test("list tasks returns 200", async ({ request }) => {
const api = new TestApi(request);
const result = await api.listTasks({ page: 1, limit: 10 });
expect(result.status).toBe(200);
expect(result.data.items.length).toBeGreaterThan(0);
});Constructor takes Playwright's APIRequestContext (the { request } fixture). Operations are async methods returning a Zod-validated ApiResponse<T>; like backend, every documented body — success or error, exact, 4XX range or default — is validated strictly, and a mismatch throws ResponseValidationError carrying the status.
backend — Node / Bun / Deno
Peer dependency: zod only. HTTP goes through the platform fetch (Node ≥ 18, Bun, Deno) — no wretch, no TanStack Query, no React. Use it for server-side code: BFFs, services, and SDK packages that wrap an API; use full for React/React Native apps.
import { ApiClient } from "./api/ApiClient";
const api = new ApiClient("https://api.example.com", {
// Called per request, so a refreshed token is always picked up (a plain HeadersInit works too).
headers: async () => ({ Authorization: `Bearer ${await tokens.current()}` }),
// Optional: any fetch-compatible function — a stub in tests, a custom agent, instrumentation.
fetch: (input, init) => fetch(input, init),
});
const res = await api.getTask({ taskId: "42" }, { signal: AbortSignal.timeout(5_000) });
if (res.status === 200 && res.data) {
console.log(res.data.title); // typed as the 200 schema
} else if (res.status === 404 && res.data) {
console.warn(res.data.message); // typed as the 404 schema
}- Constructor:
new ApiClient(baseUrl, options?). When the spec declaresservers,baseUrldefaults to the first one (ApiClient.defaultBaseUrl; all of them are onApiClient.servers), sonew ApiClient()andnew ApiClient(undefined, { headers })work. A trailing/onbaseUrlis ignored. ApiClientOptions:fetch?: (input: string, init: RequestInit) => Promise<Response>(defaults to the globalfetch, looked up per request) andheaders?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>)(sent on every request).- Operations:
async <operationId>(params, requestInit?).paramsholds path, query and header params plusbody, and defaults to{}when nothing in it is required.requestInitisRequestOptions—{ signal?: AbortSignal; headers?: HeadersInit }. - Responses:
ApiResponse<T, S>—{ data, status, headers: Headers, url }, literalstatusper exactly documented status. Every documented body is validated strictly; a mismatch throwsResponseValidationErrorcarrying the status. That holds for successes and errors alike — an exact status (200, 401, 404, 500…), a4XX-style range ordefault. A body that does not match its schema (the wrong shape, a key a strict schema does not declare, a proxy's HTML page on a documented status) is a contract violation: the call rejects with aResponseValidationErrorthat hasstatus,headers,url, the rawbody, and the ZodError (or, for a non-JSON body, the JSON SyntaxError) ascause; its message readsResponse to GET /tasks/{taskId} (500) does not match the contract. A status is matched exact → range →default(Go mode's precedence); a status none of them documents resolvesdata: null— there is nothing to validate it against. A method never throws on an HTTP status as such — only on a network error / abort, invalid params (before the request), or that contract violation. The return type is oneApiResponse<T, S>per exact status plus a catch-allApiResponse<Envelope, number>per range/default(andApiResponse<null, number>withoutdefault), so checkres.dataafter checkingstatus. - Headers are layered:
options.headers→ the body's content type → the operation's header params →requestInit.headers, each winning over the one before. A multipart or urlencoded body always gets the content type fetch writes for it, even if a defaultContent-Typeis set. - Encoding matches the other TS modes: path params percent-encoded; query arrays repeat the key (comma-joined for
explode: false), objects are sent as JSON,undefined/nullare left out; multipart viaFormData, urlencoded viaURLSearchParams, raw binary bodies as theBlob/Fileyou pass (BinaryInput = Blob | File).
swift — iOS / macOS
No external dependencies (Foundation only). Add the .swift file to your Xcode project.
let api = ApiClient(baseURL: URL(string: "https://api.example.com")!)
let response = try await api.listTasks(page: 1, limit: 10)Constructor: init(baseURL: URL, session: URLSession = .shared). Schemas are Codable structs; failures throw an APIError enum.
kotlin — Android / JVM
Uses Ktor + kotlinx.serialization.
val httpClient = HttpClient(CIO) {
install(ContentNegotiation) { json() }
}
val api = ApiClient(client = httpClient, baseUrl = "https://api.example.com")
val response = api.listTasks(page = 1, limit = 10)Constructor: ApiClient(client: HttpClient, baseUrl: String). Schemas are @Serializable data classes; operations are suspend functions returning ApiResponse<T> and throw ApiException on error.
// build.gradle.kts
plugins { kotlin("plugin.serialization") version "2.1.0" }
dependencies {
implementation("io.ktor:ktor-client-core:3.1.0")
implementation("io.ktor:ktor-client-cio:3.1.0")
implementation("io.ktor:ktor-client-content-negotiation:3.1.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
}php — Symfony HttpClient
Composer dependencies: symfony/http-client, symfony/serializer, symfony/property-info, symfony/property-access, phpdocumentor/reflection-docblock.
use App\Api\ApiClient;
use Symfony\Component\HttpClient\HttpClient;
$api = new ApiClient(HttpClient::create(), 'https://api.example.com');
$response = $api->listTasks(page: 1, limit: 10);
$response->data; // typed model, hydrated via Symfony Serializer
$response->status; // 200Constructor: new ApiClient(HttpClientInterface $client, string $baseUrl, ?SerializerInterface $serializer = null) (a sensible serializer is built by the generated SerializerFactory when omitted; the third parameter is dropped entirely for a spec whose operations never serialize or deserialize a typed body). Output is a PSR-4 directory — models under Model/, plus ApiResponse, ApiException (thrown on every non-2xx), and the client class. --php-version gates emitted features: backed enums + readonly need 8.1+, readonly class 8.2+, typed constants 8.3+, #[\Deprecated] 8.4+, #[\NoDiscard] + Uri\Rfc3986 URL building 8.5. Code generated for 8.0 uses serializer ^5.4|^6.x (AnnotationLoader); 8.1+ requires serializer >= 6.4.
php-legacy — PHP 7.3, no Composer
For consumers stuck on an old PHP (an information system on 7.3, say) that cannot install Symfony. Requirements: ext-curl and ext-json, nothing else. Unlike the php mode it writes into the output directory itself: README.md plus src/ with one class per file under --namespace (RobotLoader or any PSR-4 loader indexes src/).
openapi-client-generator spec/sync.openapi.json clients/php -n MedelySyncClient -m php-legacy \
--namespace 'Medely\Client' --php-version 7.3$client = new MedelySyncClient(
new CurlTransport(),
new ClientCredentials($clientId, $clientSecret, new FileTokenStore('/var/cache/app')),
'https://api.example.com'
);
$doctor = $client->upsertDoctor('r-1', new Model\UpsertDoctorRequest('Jana', 'Nova')); // Model\DoctorResponse- Layout:
<Client>.php(one public method per operation, returning the typed model,voidfor 204, the raw string for a byte download),Http/(Transport,CurlTransport,Request,Response),Auth/(Authenticator,ClientCredentials,TokenStore,MemoryTokenStore,FileTokenStore),Exception/(MedelyException>ApiExceptionwithgetStatusCode()/getErrorCode()/getBody()/getResponseHeaders(),TransportException,UnexpectedResponseException),Multipart/FilePart,Internal/Support,Model/*. Auth\ClientCredentialsis generated from the spec's OAuth2clientCredentialssecurity scheme: it POSTs the form with an explicitscope, caches the token (in aTokenStore) untilexpires_inminus 60 s, and retries once on a 401. An operation withsecurity: [](the token operation itself) is never authorized.- Models are immutable (private untyped properties with
@var, 7.1-style constructor hints, getters only).fromArray()checks the required keys and throwsUnexpectedResponseException;toArray()writes only declared fields, leaving out anulloptional. Date-times are\DateTimeImmutable(DATE_ATOM). Enums are lenient: the property holds the raw string and theModel\<Enum>class is a constants holder withvalues()/isKnown(), so a value the API adds later cannot crash an old client. - Banned in the output (checked by a test):
fn,match,??=,enum, typed properties (7.3), named arguments,mixedas a type,?->, attributes, union types.--php-version 7.4only switches on typed properties. - Multipart bodies go through
CURLFile(FilePart::fromString()writes a temp file, sinceCURLStringFileis 8.1+); an array field is sent asname[0],name[1].
go — standard library net/http
No dependencies beyond the standard library; Go 1.27+. The output is one package directory (client.go, operations.go, models.go, enums.go, unions.go, errors.go, runtime.go), already gofmt-formatted.
api, err := petstore.NewClient(
petstore.WithBaseURL("https://api.example.com"),
petstore.WithRequestEditor(func(ctx context.Context, req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+token)
return nil
}),
)
res, err := api.ListTasks(ctx, &petstore.ListTasksParams{Page: petstore.Ptr(1)})
res.Data // TaskList
res.StatusCode // 200
_, err = api.GetTask(ctx, "missing")
var getErr *petstore.GetTaskError
if errors.As(err, &getErr) && getErr.Status404 != nil {
fmt.Println(getErr.Status404.Message) // the documented 404 body, typed
}Every operation is (ctx, path params…, required query/header params…, body, *<Op>Params) (*APIResponse[T], error). A non-2xx response is always that operation's own *<Op>Error, which has one typed field per documented error status and unwraps to the shared *APIError. Optional fields are pointers (Ptr helps), enums are typed constants that reject undeclared values (or, with --lenient-enums, keep them — check Valid() — so an API that grows an enum value, as App Store Connect does, does not break every response carrying it), and oneOf/anyOf unions are structs with one pointer per branch.
Agent skills
The package ships agent skills — Markdown playbooks for AI coding agents covering CLI usage (generate-client) and per-mode integration (integrate-{full,playwright,backend,swift,kotlin,php,php-legacy,go}-client, integrate-zod-schemas). Compatible tools discover them automatically once the package is installed.
Development
bun install
task test # run the vitest suite
task lint # oxlint
task build # bundle src/bin.ts -> dist/bin.js
task run # generate a sample client from fixtures/api.json into out/Run a single test file: bun vitest run src/__tests__/converter/primitives.test.ts
See CLAUDE.md for architecture details and the full task list. task run-pw, task run-backend, task run-swift, task run-kotlin, task run-php, task run-php-legacy, task run-go, and task run-zod generate sample clients for the other modes. Runtime integration tests (task test-runtime) require a Java/Gradle toolchain (Kotlin), a Swift toolchain (Swift), PHP >= 8.1 + Composer (PHP), Docker with the php:7.3-cli image (php-legacy: lint, PHPStan at phpVersion: 70300 and a runtime smoke test against a stub server), or Go >= 1.27 (Go) and are skipped otherwise.
License
No license has been declared for this package yet. Add a LICENSE file and a license field to package.json / package.json.dist before publishing publicly.
