Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

apiwright

Generates typed API clients from an OpenAPI 3.1 spec:

  • Python: Pydantic v2 models over an async httpx client
  • TypeScript: types over a fetch client, with optional Zod schemas

3.0 specs are upconverted to 3.1 on load, so either version works as input.

Quick start

apiwright python -i openapi.yaml -o generated

That writes a complete, installable package. To generate both languages from a checked-in config instead:

apiwright init          # scaffolds apiwright.toml
apiwright generate      # generates every configured target

What gets written

Each output directory is a package, with one layout per language. Given package_name = "demo_client" and package_name = "demo-client":

python/                             typescript/
  pyproject.toml                      package.json
  demo_client/                        tsconfig.json
    __init__.py                       src/
    _client.py                          index.ts
    _auth.py                            client.ts
    _errors.py                          auth.ts
    _serde.py                           errors.ts
    py.typed                            models/
    models/                             api/
    api/                              tests/
  tests/                            .apiwright-manifest.json
  .apiwright-manifest.json

models/ holds one file per schema, api/ one file per tag. tests/ holds generated self-tests (round-trip and operation-signature checks) and can be turned off with emit_self_tests.

The package name is resolved in this order: the package_name config key, then --package-name, then the spec's info.title recased for the language, then client.

Maps and open objects

A schema whose only content is additionalProperties: <schema> is a map, and becomes a type alias: dict[str, V] in Python, Record<string, V> in TypeScript.

A schema with declared properties and an additionalProperties schema keeps both. Python declares __pydantic_extra__: dict[str, V] under extra="allow", so the extra keys are kept and validated instead of dropped. TypeScript adds an index signature, and there the value type may be wider than the spec: TypeScript requires an index signature to accept every declared property, so a string property beside integer extras yields [key: string]: number | string. Zod is not affected, since catchall applies only to keys the object does not declare.

additionalProperties: true and additionalProperties: false carry no value type, so they change nothing about how the model renders.

Generic envelopes

A spec that parameterizes a wrapper type through JSON Schema's $dynamicRef and $dynamicAnchor gets one generic type rather than one concrete type per item type. An envelope declares its parameters as bare $dynamicAnchor entries in $defs and references them with $dynamicRef; an instantiation is a $ref to the envelope plus a $defs map binding each anchor:

Page:
  $id: schemas/Page
  type: object
  required: [items, total]
  properties:
    items:
      type: array
      items: {$dynamicRef: "#pageItem"}
    total: {type: integer}
  $defs:
    defaultItem: {$dynamicAnchor: pageItem}

WidgetPage:
  $id: schemas/WidgetPage
  $ref: Page
  $defs:
    boundItem:
      $dynamicAnchor: pageItem
      $ref: ../openapi.json#/components/schemas/Widget

That emits Page<PageItem> in TypeScript and Page(BaseModel, Generic[PageItem]) in Python, and operations returning a widget page are typed Page<Widget> and Page[Widget]. The type parameter takes the anchor's name, recased for the language. Zod has no value-level generics, so an envelope becomes a factory function, PageSchema(WidgetSchema).

The instantiation component itself emits nothing: WidgetPage is a name the reader understands and the output never mentions. An instantiation may itself be bound as an argument, which nests: Page<Page<Widget>>, Page[Page[Widget]], PageSchema(PageSchema(WidgetSchema)).

References may be relative, as above, or absolute. Relative ones resolve against a base the reader supplies, so a spec needs no authority of its own; inside a component carrying an $id, that base is the $id.

That applies to every $ref in the component, not just the ones binding anchors. A fragment-only $ref such as #/components/schemas/Widget written inside an $id-bearing component points within that component rather than at the document, and so reaches nothing; write it path-relative to the $id, as ../openapi.json#/components/schemas/Widget. openapi.json here is apiwright's own fixed name for the document, not the input file's actual name; write it exactly as shown, whatever the file is called.

Only this shape is recognized. A $dynamicRef used any other way, an instantiation that binds the wrong anchors or none, a cycle of instantiations and a recursive envelope are all errors naming the component, rather than a plausible type that is wrong. Two more shapes are rejected for the same reason: an anchor whose recased name is also the name of a schema declared in components/schemas, or lifted out of an inline object inside one, which would make the parameter shadow that schema, and a $dynamicRef inside an inline object, since an inline object is lifted into a schema of its own and could not declare the parameter. $anchor, the static form, is ignored.

The manifest

Every run writes <output>/.apiwright-manifest.json, recording the hash of each file it owns. On the next run:

  • A file whose content still matches the manifest is rewritten or deleted freely
  • A file that has been hand-edited since generation is blocked, and the run reports it rather than overwriting it. --force overrides this
  • A file the generator no longer emits is deleted, and directories left empty by that deletion are pruned
  • Anything not in the manifest is left alone

.apiwright-manifest.json.lock sits alongside it and holds an advisory lock for the duration of a run, so two concurrent runs against one output cannot interleave. It is expected to persist between runs; the OS releases the lock if the process dies.

Generating into a directory that contains the input spec is refused, since that would feed the generator its own output.

Configuration

Config can live in any one of three hosts, all with the same keys:

  • apiwright.toml (tables at the top level)
  • pyproject.toml, under [tool.apiwright]
  • package.json, under an "apiwright" object

Discovery walks up from the working directory and stops at the first directory containing a config, or at a .git directory. Two config hosts in the same directory is an error rather than a precedence rule. -c/--config names one explicitly.

input = "openapi.yaml"

[python]
output = "generated/python"
package_name = "demo_client"

[typescript]
output = "generated/typescript"
package_name = "demo-client"
emit_zod = true

Keys

input is either a path string, relative to the config file, or a table:

input = { type = "file", path = "openapi.yaml" }

# Or fetch the spec from a command's stdout, for a spec that is generated
# rather than checked in:
input = { type = "command", command = ["python", "-m", "myapp.openapi"], cwd = ".", env = { ENVIRONMENT = "dev" } }

cwd is relative to the config file and defaults to it; env is merged onto the inherited environment.

[python] and [typescript] are both optional, and a target is generated only if its table is present.

Key Default Applies to
output required both
package_name required both
method_naming snake_case (Python), camel_case (TypeScript) both
emit_self_tests true both
post_emit_hook none both
emit_pyproject true Python
emit_package_json true TypeScript
emit_tsconfig true TypeScript
emit_zod false TypeScript

method_naming is one of snake_case, camel_case, or preserve.

post_emit_hook is a command run in the output directory after a successful write, for a formatter:

post_emit_hook = ["ruff", "format", "."]

A hook that reformats generated files does not cause the next run to report them as hand-edited. --no-post-emit-hook skips it.

Operation names

[operation_names] rewrites operationIds before they become method names. Rules run in order.

[operation_names]
rules = [
  { type = "strip_fastapi_suffix" },
  { type = "regex", match = "^Api_", replace = "", languages = ["python"] },
]

type = "regex" requires match and replace. type = "strip_fastapi_suffix" takes neither: FastAPI appends the path and method to every operationId, so get_notification_notifications__notification_key__get becomes get_notification. The suffix is reconstructed from the operation's own path and method and matched exactly, so an id from any other generator is left alone rather than guessed at.

languages limits a rule to python, typescript, or both; omitting it applies the rule everywhere.

These rules also name the models synthesised from inline request and response bodies, so a short bulk_create_works() returns a BulkCreateWorksResponse rather than a BulkCreateWorksWorksBulkPostResponse. Only rules with no languages restriction do this, since a model name is shared by both targets.

Commands

Command Purpose
apiwright generate Generate every target in the config
apiwright python Generate a Python client from a spec, no config needed
apiwright typescript Generate a TypeScript client from a spec, no config needed
apiwright init Scaffold a config. --into pyproject.toml or --into package.json writes into an existing file
apiwright check Parse, upconvert and normalize without emitting
apiwright print-ir Print the normalized IR as JSON, for debugging

The generating commands share --force, --dry-run, and --json. --dry-run reports what would change and creates nothing, not even the output directory. generate also takes -i/--input and -o/--output to override the config, and --no-post-emit-hook. --output is rejected when more than one target is enabled, since there would be no way to say which one it meant.

check and print-ir take either -c/--config or -i/--input. print-ir output is a function of the spec alone: naming config is applied later, on the way to the emitters, so what it prints is the IR before any renaming.

Development

The repository is a Rust workspace of four crates: apiwright-core (IR, normalisation, naming), apiwright-python, apiwright-typescript, and apiwright (the CLI). A nix flake pins the toolchain.

nix develop --command cargo test --workspace
nix develop --command cargo clippy --workspace --all-targets -- -D warnings
nix develop --command cargo fmt --all --check

The corpus gate

Unit tests check what the emitters produce. The corpus gate checks that the output actually works, by generating a client from each spec in corpus/ and then running it:

nix develop .#corpus --command cargo test -p apiwright --features corpus --test corpus

It is one test case per spec, Zod mode and language, so it parallelises and filters like any other cargo test: add a substring to run one of them.

The corpus feature gates the test target rather than the test skipping itself when its toolchains are missing. Without the feature the target is not compiled, so cargo test --workspace does not silently half-run it, and with the feature a missing python3, node, tsc, ruff or ty is a hard failure naming the shell to run under.

For each spec, in both Zod modes, it checks that the Python package imports and constructs, that the generated self-tests pass, that retries and backoff behave against a local server that fails on demand, and that ruff and ty are clean. Then the same for TypeScript under tsc and node --test.

Expectations specific to one spec live beside it, and run with the generated package importable:

  • corpus/<name>.checks.py runs against the Python package
  • corpus/<name>.checks.ts is copied into the package's tests/, so tsc checks it and then node runs it

A spec with no sidecar reports its expectations case as ignored rather than passing, so a missing expectation cannot be mistaken for coverage. Adding a spec to corpus/ is enough to have it generated and run; a sidecar is only needed if the spec is demonstrating something in particular.

An empty corpus is a failure rather than a pass, since a suite that checks nothing must not report success. The npm dependencies the generated TypeScript resolves through are pinned in scripts/corpus-npm/package-lock.json and installed with npm ci.

corpus/fastapi-shapes.yaml is a hand-written spec carrying the shapes real FastAPI output has and hand-written specs usually do not: mangled operationIds, inline request and response bodies, recursive schemas, and enums with defaults. It exists so that fidelity fixes are verified against something the generator will actually meet.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

apiwright-0.1.0rc6-py3-none-win_arm64.whl (1.7 MB view details)

Uploaded Python 3Windows ARM64

apiwright-0.1.0rc6-py3-none-win_amd64.whl (1.8 MB view details)

Uploaded Python 3Windows x86-64

apiwright-0.1.0rc6-py3-none-musllinux_1_2_x86_64.whl (1.9 MB view details)

Uploaded Python 3musllinux: musl 1.2+ x86-64

apiwright-0.1.0rc6-py3-none-musllinux_1_2_aarch64.whl (1.7 MB view details)

Uploaded Python 3musllinux: musl 1.2+ ARM64

apiwright-0.1.0rc6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (1.9 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ x86-64

apiwright-0.1.0rc6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (1.7 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ ARM64

apiwright-0.1.0rc6-py3-none-macosx_11_0_arm64.whl (1.7 MB view details)

Uploaded Python 3macOS 11.0+ ARM64

apiwright-0.1.0rc6-py3-none-macosx_10_12_x86_64.whl (1.8 MB view details)

Uploaded Python 3macOS 10.12+ x86-64

File details

Details for the file apiwright-0.1.0rc6-py3-none-win_arm64.whl.

File metadata

  • Download URL: apiwright-0.1.0rc6-py3-none-win_arm64.whl
  • Upload date:
  • Size: 1.7 MB
  • Tags: Python 3, Windows ARM64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for apiwright-0.1.0rc6-py3-none-win_arm64.whl
Algorithm Hash digest
SHA256 67c9bca212f608c648db5e62ba8db647fc5f0d7aed4090dd230c7e95068ff850
MD5 4bdd2294d2d6d8272306e255e122f192
BLAKE2b-256 b95fdcd9d838f5cc461bf52454ba11bc216ea928d9b821a4143edd047d1cabd8

See more details on using hashes here.

File details

Details for the file apiwright-0.1.0rc6-py3-none-win_amd64.whl.

File metadata

  • Download URL: apiwright-0.1.0rc6-py3-none-win_amd64.whl
  • Upload date:
  • Size: 1.8 MB
  • Tags: Python 3, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for apiwright-0.1.0rc6-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 45f49e7f61bdf78f907225b6817f4086e0ae0ecb8768292c6beec9352f16c99e
MD5 03ed39a8277c6b1189e28d2d6517f150
BLAKE2b-256 b5281be3ebe06ca2156f264d0c761b1ee90274ee7306df5077b9233cdf2144fb

See more details on using hashes here.

File details

Details for the file apiwright-0.1.0rc6-py3-none-musllinux_1_2_x86_64.whl.

File metadata

File hashes

Hashes for apiwright-0.1.0rc6-py3-none-musllinux_1_2_x86_64.whl
Algorithm Hash digest
SHA256 0c41dfb30b73fc9fbf50a356c04e6e0f21b43dc7025b96875895874f0f7c5dd5
MD5 862df1c9912d9897ec7c2aa02a72a716
BLAKE2b-256 29ec45669d0b50c0860a6149e359a7e05d5985987f55bfb1f1957de54ffa96f0

See more details on using hashes here.

File details

Details for the file apiwright-0.1.0rc6-py3-none-musllinux_1_2_aarch64.whl.

File metadata

File hashes

Hashes for apiwright-0.1.0rc6-py3-none-musllinux_1_2_aarch64.whl
Algorithm Hash digest
SHA256 c34357f89e433182ce10b436f1098e51ffad1b64fbb5d2c9fa30c136bd527ec9
MD5 cc5300aafc9eaf8defe0cec03674fdc5
BLAKE2b-256 b847cb9a1925727a218b0705bae33966b1d89693c828babc374ab7ef9b4da455

See more details on using hashes here.

File details

Details for the file apiwright-0.1.0rc6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for apiwright-0.1.0rc6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 630f7421c8aaddd0607cd6baec933383bca54244937fa3af4b952f841c84e21e
MD5 f764cefa3d32cc00329a55765a9cb466
BLAKE2b-256 963d53daea1dc2863239b19232e1a74f141bb7ec914d21159624748a024188f9

See more details on using hashes here.

File details

Details for the file apiwright-0.1.0rc6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for apiwright-0.1.0rc6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 49cb8c64b10d8efdc26fd87b8ce34f6ca473cdc33c4ab685499b995760cd12bc
MD5 412606f0cba0d10b7789bf7b406845d1
BLAKE2b-256 a085a4013b753035f30d89baed894d04f8d0f593c1c857ff933b987b2f87c783

See more details on using hashes here.

File details

Details for the file apiwright-0.1.0rc6-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for apiwright-0.1.0rc6-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 54dedd255009586b26591686b1f4a3e87a7dcf770c9ac0461a799683fdeeafcc
MD5 712396dd33dc0e484f34f3f25ceda8d0
BLAKE2b-256 355287977509628b1b5ba9ab6ce8d22fa8222a0c031c112022d22f314a9a66e1

See more details on using hashes here.

File details

Details for the file apiwright-0.1.0rc6-py3-none-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for apiwright-0.1.0rc6-py3-none-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 ed6f9c7eb1092578dbed76907f572f596e00806b80cd197d51d2e682ae77ab85
MD5 12ea4549d61e714a3bcdd609c1eee2ef
BLAKE2b-256 9f57775ccc986e96b033f58b3d540c2bd7fc5e07cb280e6f48a3e156ca51faeb

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0rc6 This release

8 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page