How to Generate a Typed API Client From an OpenAPI Spec
Every team writes the same file eventually. A thin wrapper around fetch or requests, one method per endpoint, a couple of dataclasses, a token read from the environment. It works, and then the API adds a field, and your wrapper does not know, and you find out in production because a value your code assumed was there is None.
If the API publishes an OpenAPI spec, you can skip the whole genre. Generate the client, regenerate it when the spec changes, and let the type checker tell you what broke instead of a customer. This guide uses PandaStack's spec as the worked example — it is public at https://api.pandastack.ai/openapi.json — but nothing here is specific to us. The technique is the same for any OpenAPI 3.1 API.
Step 1: get the spec and read its shape
curl -sO https://api.pandastack.ai/openapi.json
# What am I dealing with?
python3 - <<'PY'
import json
spec = json.load(open("openapi.json"))
print("openapi:", spec["openapi"])
print("paths: ", len(spec["paths"]))
print("auth: ", list(spec["components"]["securitySchemes"]))
PY
# openapi: 3.1.0
# paths: 115
# auth: ['bearerAuth']Check the version first, because it decides your generator. OpenAPI 3.1 aligned itself with JSON Schema, which is a genuine improvement and also the reason older generators choke on it — a tool built for 3.0 will quietly mishandle things like nullable types expressed as type arrays. If your generator does not say 3.1 in its documentation, do not assume it works and be surprised later.
Step 2a: TypeScript
The approach I recommend is types-plus-thin-client rather than a full class-based SDK generator. openapi-typescript turns the spec into type declarations with no runtime, and openapi-fetch is a tiny typed wrapper around fetch that consumes those types. You get autocomplete on paths, request bodies, and responses, and you ship almost no generated code.
npm i -D openapi-typescript
npm i openapi-fetch
npx openapi-typescript https://api.pandastack.ai/openapi.json \
-o src/api/schema.d.tsimport createClient from "openapi-fetch";
import type { paths } from "./api/schema";
const api = createClient<paths>({
baseUrl: "https://api.pandastack.ai",
headers: { Authorization: `Bearer ${process.env.PANDASTACK_API_KEY}` },
});
// The path, the method, and the body are all checked against the spec.
const { data, error } = await api.POST("/v1/sandboxes", {
body: { template: "base", ttl_seconds: 600 },
});
if (error) throw new Error(`create failed: ${JSON.stringify(error)}`);
console.log(data.id);Mistype the path and the compiler tells you. Send a field the endpoint does not accept and the compiler tells you. Regenerate after a spec change and every call site that no longer matches lights up — which is the entire point, and the thing a hand-written wrapper can never do.
Step 2b: Python
On the Python side the pragmatic choice is openapi-python-client, which produces a package of attrs models and per-endpoint modules, fully type-annotated so mypy and your editor can see it.
pipx install openapi-python-client
openapi-python-client generate \
--url https://api.pandastack.ai/openapi.json
# Regenerating later, in place:
openapi-python-client update --url https://api.pandastack.ai/openapi.jsonTwo habits make this pleasant rather than annoying. Commit the generated code so reviewers can see what an API change did to your surface — a diff of generated models is one of the more useful code reviews you will get for free. And do not hand-edit it, ever: put your convenience helpers in a separate module that imports the generated one, or your next regeneration eats your changes.
Step 2c: Go
For Go, oapi-codegen is the standard answer and integrates neatly with go generate, so the client regenerates as part of your normal build workflow rather than as a step somebody forgets.
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen \
// -generate types,client -package pandastack -o client.gen.go openapi.json
package main
func main() {
c, _ := pandastack.NewClientWithResponses(
"https://api.pandastack.ai",
pandastack.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+os.Getenv("PANDASTACK_API_KEY"))
return nil
}),
)
// c.PostV1SandboxesWithResponse(ctx, body) — typed request and response.
}Step 3: the three places generators fall over
Streaming endpoints. Server-sent events and WebSockets are the most common gap. A generated client will typically give you a method that returns the whole body, which is useless for an endpoint designed to stream — log tails, exec output, deployment logs. Write those few by hand and use the generated types for their payloads. That is the right split, not a defeat.
Long polling and async operations. Endpoints that return 202 and expect you to poll are correct OpenAPI and terrible generated ergonomics, because the generator has no idea the operation is not finished. Wrap them in a helper that polls with backoff and hides the pattern from callers.
Auth refresh. Generated clients take a token; they do not know how to get a new one. Inject a request hook that supplies the current credential rather than baking a string in at construction time — every generator on this list supports one, and it is the difference between a client that works for an hour and one that works for a month.
Step 4: stop the spec from lying
A generated client is only as good as the spec, and hand-maintained specs drift the moment someone ships a route without updating the document. The fix is a test rather than a policy: harvest the routes the server actually registers, compare them to the spec, and fail the build on a mismatch.
We do exactly this — a test walks the route registrations in the source, diffs them against openapi.json, and fails if a public route is undocumented or a documented route no longer exists, with an explicit exemption list for internal and webhook-signed endpoints. It is maybe a hundred lines and it is the reason the spec above can be trusted enough to generate from. If you publish a spec, write that test. If you consume one, ask the vendor whether they have.
When not to bother
If the vendor ships a real SDK, use it. A generated client gives you types; a maintained SDK gives you retries, pagination helpers, streaming, and error types someone thought about. Generate when there is no SDK for your language, when you need a subset small enough that a dependency is not worth it, or when you are building against an internal service that will never have one. Do not generate a worse version of something you could have installed.
Frequently asked questions
Should I commit generated client code?
Yes, in almost every case. Committing it means builds do not depend on a remote spec being reachable, reviewers can see exactly what an API change did to your surface, and you can bisect when behaviour shifts. The objection — that generated code clutters the diff — is real but minor next to the alternative, which is a build that fails on a Monday because someone's spec endpoint returned a 502.
How do I handle streaming endpoints with a generated client?
Hand-write those, and use the generated types for their payloads. Server-sent events and WebSockets do not fit the request-response shape generators emit, and forcing them produces a method that buffers a stream you wanted incrementally. There are usually only a handful of streaming routes in an API — exec output, log tails, deploy logs — so this is a small, contained exception rather than a reason to abandon generation.
What breaks when a spec moves from OpenAPI 3.0 to 3.1?
Mostly nullability and schema composition. 3.1 aligns with JSON Schema, so nullable fields become type arrays rather than a nullable flag, and older generators either fail loudly or — worse — emit a type that quietly drops the null. Check that your generator advertises 3.1 support, and after regenerating, spot-check a few optional fields in the output rather than assuming the tool handled it.
How do I keep the client from drifting out of sync with the API?
Two mechanisms, and you want both. On the server, a test that compares registered routes against the published spec and fails the build on a mismatch, so the spec cannot silently become fiction. On the client, a scheduled job that regenerates and opens a pull request when the output changes, so an API addition shows up as a reviewable diff rather than a runtime surprise. Neither is much code, and together they make generated clients boring in the best way.
Keep reading
- The PandaStack platform — sandboxes, databases, apps, one API
- Receiving webhooks for deploys and quota events
- Streaming command output from a sandbox
- Managing environment variables and secrets
49ms p50 cold start. Fork, snapshot, and scale to zero.