Backend & APIs No. 091

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.

  • nameopenapi-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.

~/.claude/skills/openapi-codegen/SKILL.md

--- name: openapi-codegen 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,… license: MIT ---

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

SituationApproach
API consumed by other teams, mobile, or partnersSpec-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-codedSpec-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 like GetOrders200ResponseDataItem.
  • required arrays are accurate; missing ones make every field optional.
  • Nullability: 3.1 uses type: [string, "null"]; 3.0 uses nullable: true. Mixing produces wrong types.
  • oneOf / anyOf unions have a discriminator with propertyName so generators emit tagged unions instead of unknown or 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

TargetDefaultWhy / escape hatch
TypeScript, lightweight fetch clientopenapi-typescript + openapi-fetchTypes only, no runtime per endpoint, tiny bundle
TypeScript + TanStack Query hooks, MSW mocks, ZodorvalHooks and mocks from one config
TypeScript, plugin-based (SDK, Zod, TanStack Query)@hey-api/openapi-tsActive, flexible; pre-1.0, so pin exact versions
Java, Kotlin, Python, Go, C#, many others; server stubsopenapi-generatorBroadest language coverage; quality varies per generator
Go server and clientoapi-codegen (strict server)Idiomatic net/http, chi, echo, gin interfaces
.NET, Java, Go, Python, TS clients from one toolKiotaMicrosoft-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 whenGenerate at build when
Reviewers should see API changes as code diffsOutput is large and noisy
Consumers build without Java/Node generator toolchainsEvery 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-time becomes string in 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.
  • additionalProperties omitted means “allowed” in JSON Schema; some generators then add index signatures that weaken types. Set additionalProperties: false where intended.
  • Enum additions break exhaustive switch statements 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 allOf inheritance inconsistently; prefer composition with a discriminator over deep allOf chains.

Verification

  1. python3 scripts/codegen_readiness.py reports no errors.
  2. Regenerating from a clean checkout yields zero diff.
  3. oasdiff breaking is empty or every item is approved with a migration note.
  4. 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 153 lines · 5.9 KB
#!/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.

esc