OpenAPI Codegen skill for Claude Code
Generated clients that stay in sync with the spec. Claude picks one generator per language from a decision table, fixes the spec first so names and types come out clean (operationIds, named schemas, discriminators, accurate required lists), pins tool versions behind a single gen script, and adds CI gates for lint, oasdiff breaking changes, regeneration drift and consumer typechecks. A bundled checker flags spec problems before any code is generated.
- name
openapi-codegen - size211 lines · ≈2,100 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Generates typed API clients and server stubs from OpenAPI 3.0 and 3.1 specs with openapi-typescript plus openapi-fetch, orval, Hey API, openapi-generator, oapi-codegen, and Kiota, prepares specs so the generated code is usable, and keeps spec and code in sync with CI drift checks and breaking-change diffs. Use when choosing a code generator, generated types come out as unknown or awkward names, adding React Query hooks or Zod schemas from a spec, generating server interfaces, or the spec and implementation have drifted apart. Try asking
- Generate a typed TypeScript client with React Query hooks from our openapi.yaml
- Our generated types are full of names like GetOrders200Response. Clean up the spec
- Set up CI so the committed API client can never drift from the spec
Use it when
- Choosing a generator for clients or server stubs
- Generated code has unknown types, awful names or missing fields
- Adding TanStack Query hooks, MSW mocks or Zod schemas from a spec
- Spec and implementation have drifted and need a CI guard
Skip it when
- You are designing the endpoints and spec themselves — use rest-api-design
- Both sides are TypeScript in one repo with no external consumers — use trpc-apis
- You need consumer-driven contracts between services — use contract-testing
How to check the result
The readiness check reports no errors, regenerating from a clean checkout produces no diff, oasdiff shows no unapproved breaking changes, and consumers typecheck.
The common mistake it prevents
Hand-editing generated files to fix a type, which is silently overwritten on the next regeneration while the real spec bug stays in place.
Done well means: one spec is the source of truth, generation is a single scripted command pinned to exact tool versions, CI fails when committed output is stale or the spec breaks existing clients, and generated code is never edited by hand.
Workflow
- [ ] 1. Decide spec-first or code-first and where the spec lives
- [ ] 2. Make the spec codegen-ready (run the readiness check)
- [ ] 3. Pick the generator per target with the decision table
- [ ] 4. Pin versions and script generation
- [ ] 5. Decide commit-or-build for generated output
- [ ] 6. Add CI gates: lint, breaking diff, drift, typecheck
1. Source of truth
| Situation | Approach |
|---|---|
| API consumed by other teams, mobile, or partners | Spec-first: edit openapi.yaml, generate server interfaces and clients |
Framework emits accurate specs (FastAPI, ASP.NET Core MapOpenApi, springdoc, NestJS swagger) | Code-first: export the spec in CI, commit it, generate clients from the exported file |
| Spec hand-written but server hand-coded | Spec-first plus response validation in contract tests, or drift is guaranteed |
For code-first, export to a committed file (for example GET /openapi.json in a test
or a CLI export step) so reviewers see spec diffs in PRs.
2. Make the spec codegen-ready
Generators turn spec shape directly into names and types. Fix these before generating:
- Every operation has a unique, stable
operationId(listOrders,getOrder); it becomes the function or hook name. Renaming it is a breaking change for generated clients. - Request and response bodies reference named schemas in
components/schemas. Inline objects produce names likeGetOrders200ResponseDataItem. requiredarrays are accurate; missing ones make every field optional.- Nullability: 3.1 uses
type: [string, "null"]; 3.0 usesnullable: true. Mixing produces wrong types. oneOf/anyOfunions have adiscriminatorwithpropertyNameso generators emit tagged unions instead ofunknownor giant intersections.- Operations carry
tags; generators split files by tag. - Every operation declares at least one 2xx response with a schema (or explicit 204).
Run the bundled checker on a JSON spec (bundle YAML first):
npx @redocly/cli bundle openapi.yaml --output openapi.bundled.json
python3 scripts/codegen_readiness.py openapi.bundled.json
It reports missing or duplicate operationIds, inline object bodies, unions without a
discriminator, untagged operations, undeclared path parameters, and operations without a
2xx schema, and exits 1 on errors. If PyYAML is installed it also reads YAML directly.
3. Choose the generator
| Target | Default | Why / escape hatch |
|---|---|---|
| TypeScript, lightweight fetch client | openapi-typescript + openapi-fetch | Types only, no runtime per endpoint, tiny bundle |
| TypeScript + TanStack Query hooks, MSW mocks, Zod | orval | Hooks and mocks from one config |
| TypeScript, plugin-based (SDK, Zod, TanStack Query) | @hey-api/openapi-ts | Active, flexible; pre-1.0, so pin exact versions |
| Java, Kotlin, Python, Go, C#, many others; server stubs | openapi-generator | Broadest language coverage; quality varies per generator |
| Go server and client | oapi-codegen (strict server) | Idiomatic net/http, chi, echo, gin interfaces |
| .NET, Java, Go, Python, TS clients from one tool | Kiota | Microsoft-maintained, consistent across languages |
Prefer one generator per language across the org; mixing them means two sets of naming rules for the same API.
4. Pin and script
openapi-typescript + openapi-fetch
npm i -D openapi-typescript@7 && npm i openapi-fetch
npx openapi-typescript ./openapi.yaml -o ./src/api/schema.d.ts
import createClient, { type Middleware } from "openapi-fetch";
import type { paths } from "./api/schema";
const api = createClient<paths>({ baseUrl: import.meta.env.VITE_API_URL });
const auth: Middleware = {
onRequest({ request }) {
request.headers.set("Authorization", `Bearer ${getToken()}`);
return request;
},
};
api.use(auth);
const { data, error } = await api.GET("/orders/{orderId}", {
params: { path: { orderId } },
});
if (error) throw new ApiError(error); // error typed from the spec's error responses
orval (orval.config.ts)
import { defineConfig } from "orval";
export default defineConfig({
orders: {
input: { target: "./openapi.yaml" },
output: {
mode: "tags-split",
target: "src/api/gen",
schemas: "src/api/gen/model",
client: "react-query",
httpClient: "fetch",
mock: true, // MSW handlers with faker data
clean: true,
},
},
});
Option names shift between orval majors; confirm against npx orval --help and the docs
for the pinned version.
openapi-generator (Java runtime required; the npm wrapper downloads the JAR)
npx @openapitools/openapi-generator-cli version-manager set 7.14.0 # writes openapitools.json
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml -g spring -o server/generated \
--additional-properties=interfaceOnly=true,useSpringBoot3=true,useTags=true
npx @openapitools/openapi-generator-cli generate -i openapi.yaml -g python -o clients/python \
--additional-properties=packageName=acme_client
Use the latest 7.x the team has validated; the version above is an example. Use
.openapi-generator-ignore to protect hand-written files, and generate server code as
interfaces (interfaceOnly, delegatePattern) so implementations live outside the
generated directory.
oapi-codegen (Go 1.24+ tool dependency)
go get -tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
go tool oapi-codegen -config oapi.yaml openapi.yaml
# oapi.yaml
package: api
output: internal/api/api.gen.go
generate:
std-http-server: true
strict-server: true
models: true
Wrap every invocation in one script (npm run gen:api, make gen) so CI and humans run
the identical command.
5. Commit or generate at build time
| Commit generated code when | Generate at build when |
|---|---|
| Reviewers should see API changes as code diffs | Output is large and noisy |
| Consumers build without Java/Node generator toolchains | Every build environment has the generator pinned |
Go projects (convention: go generate output is committed) | Single-repo TS app where postinstall/prebuild runs it |
Either way, mark generated paths in .gitattributes (linguist-generated=true) and exclude
them from lint and coverage. Never hand-edit generated files; fix the spec or the template.
6. CI gates
npx @redocly/cli lint openapi.yaml
oasdiff breaking origin-main-openapi.yaml openapi.yaml --fail-on ERR
npm run gen:api && git diff --exit-code -- src/api/gen # drift check if committed
npx tsc --noEmit # consumers still compile
The typecheck after regeneration is the most valuable gate: a changed field shows up as a compile error at every call site that depends on it.
Gotchas
format: date-timebecomesstringin most TS generators; convert at the boundary rather than lying in the type.- Integer IDs above 2^53 lose precision in JS; spec them as strings.
additionalPropertiesomitted means “allowed” in JSON Schema; some generators then add index signatures that weaken types. SetadditionalProperties: falsewhere intended.- Enum additions break exhaustive
switchstatements in generated clients; document enums as open or handle a default branch. - A spec served by the running app can differ from the committed one (feature flags, environment-dependent routes); diff them in CI for code-first setups.
- Generators handle
allOfinheritance inconsistently; prefer composition with a discriminator over deepallOfchains.
Verification
python3 scripts/codegen_readiness.pyreports no errors.- Regenerating from a clean checkout yields zero diff.
oasdiff breakingis empty or every item is approved with a migration note.- Consumers typecheck, and one integration test calls the real server through the generated client.
When the generated output looks wrong, fix the spec first (names, required, discriminator), regenerate, and only then consider generator options or custom templates.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/codegen_readiness.py
#!/usr/bin/env python3
"""Check an OpenAPI 3.x spec for problems that make generated clients ugly or wrong.
Usage:
python3 scripts/codegen_readiness.py openapi.json [--strict]
Reads JSON with the standard library. YAML works only if PyYAML is installed;
otherwise bundle to JSON first:
npx @redocly/cli bundle openapi.yaml --output openapi.bundled.json
Exit codes: 0 = no errors (warnings allowed unless --strict), 1 = errors found,
2 = unreadable input.
"""
import json
import re
import sys
from collections import defaultdict
METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
def load(path):
with open(path, encoding="utf-8") as fh:
text = fh.read()
try:
return json.loads(text)
except json.JSONDecodeError:
try:
import yaml # type: ignore
except ImportError:
sys.exit(f"error: {path} is not JSON and PyYAML is not installed; bundle to JSON first")
return yaml.safe_load(text)
def is_inline_object(schema):
"""True when a body schema is an anonymous object instead of a $ref."""
if not isinstance(schema, dict) or "$ref" in schema:
return False
if schema.get("type") == "array":
return is_inline_object(schema.get("items", {}))
t = schema.get("type")
return (t == "object" or (isinstance(t, list) and "object" in t)) and bool(schema.get("properties"))
def walk_unions(node, where, out):
"""Collect oneOf/anyOf with 2+ object branches and no discriminator."""
if isinstance(node, dict):
for key in ("oneOf", "anyOf"):
branches = node.get(key)
if isinstance(branches, list) and len(branches) > 1 and "discriminator" not in node:
refs = [b for b in branches if isinstance(b, dict) and ("$ref" in b or b.get("type") == "object")]
if len(refs) > 1:
out.append(f"{where}: {key} with {len(branches)} object branches has no discriminator")
for k, v in node.items():
walk_unions(v, f"{where}/{k}", out)
elif isinstance(node, list):
for i, v in enumerate(node):
walk_unions(v, f"{where}/{i}", out)
def main(argv):
if len(argv) < 2 or argv[1] in ("-h", "--help"):
print(__doc__)
return 2
strict = "--strict" in argv
try:
spec = load(argv[1])
except OSError as exc:
print(f"error: cannot read {argv[1]}: {exc}")
return 2
if not isinstance(spec, dict) or "paths" not in spec:
print("error: not an OpenAPI document (no 'paths')")
return 2
errors, warnings = [], []
version = str(spec.get("openapi", "?"))
op_ids = defaultdict(list)
op_count = 0
for path, item in (spec.get("paths") or {}).items():
if not isinstance(item, dict):
continue
path_level_params = item.get("parameters", [])
template_params = set(re.findall(r"{([^}]+)}", path))
for method in METHODS:
op = item.get(method)
if not isinstance(op, dict):
continue
op_count += 1
label = f"{method.upper()} {path}"
oid = op.get("operationId")
if not oid:
errors.append(f"{label}: missing operationId (generated name will be derived from the path)")
else:
op_ids[oid].append(label)
if not op.get("tags"):
warnings.append(f"{label}: no tags (generators cannot split files by tag)")
declared = {
p.get("name")
for p in list(path_level_params) + list(op.get("parameters", []))
if isinstance(p, dict) and p.get("in") == "path"
}
has_ref_params = any(isinstance(p, dict) and "$ref" in p for p in list(path_level_params) + list(op.get("parameters", [])))
missing = template_params - declared
if missing and not has_ref_params:
errors.append(f"{label}: path parameter(s) {sorted(missing)} not declared")
body = op.get("requestBody", {})
for ctype, media in (body.get("content") or {}).items():
if is_inline_object(media.get("schema")):
warnings.append(f"{label}: inline request schema for {ctype}; move to components/schemas")
responses = op.get("responses") or {}
success = [c for c in responses if str(c).startswith("2")]
if not success:
errors.append(f"{label}: no 2xx response declared")
for code in success:
resp = responses[code]
if not isinstance(resp, dict) or "$ref" in resp:
continue
content = resp.get("content")
if not content and str(code) != "204":
warnings.append(f"{label}: {code} response has no content schema (client gets unknown/void)")
for ctype, media in (content or {}).items():
if is_inline_object(media.get("schema")):
warnings.append(f"{label}: inline {code} response schema for {ctype}; move to components/schemas")
for oid, where in op_ids.items():
if len(where) > 1:
errors.append(f"duplicate operationId '{oid}' on {', '.join(where)}")
elif not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", oid):
warnings.append(f"operationId '{oid}' has characters that generators will mangle")
unions = []
walk_unions(spec.get("components", {}), "#/components", unions)
walk_unions(spec.get("paths", {}), "#/paths", unions)
warnings.extend(unions)
print(f"OpenAPI {version}: {op_count} operations checked")
for e in errors:
print(f"ERROR {e}")
for w in warnings:
print(f"WARN {w}")
print(f"\n{len(errors)} error(s), {len(warnings)} warning(s)")
if errors or (strict and warnings):
return 1
print("Spec is codegen-ready.")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.