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.

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.0rc5-py3-none-win_arm64.whl (1.6 MB view details)

Uploaded Python 3Windows ARM64

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

Uploaded Python 3Windows x86-64

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

Uploaded Python 3musllinux: musl 1.2+ x86-64

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

Uploaded Python 3musllinux: musl 1.2+ ARM64

apiwright-0.1.0rc5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (1.8 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ x86-64

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

Uploaded Python 3manylinux: glibc 2.17+ ARM64

apiwright-0.1.0rc5-py3-none-macosx_11_0_arm64.whl (1.6 MB view details)

Uploaded Python 3macOS 11.0+ ARM64

apiwright-0.1.0rc5-py3-none-macosx_10_12_x86_64.whl (1.7 MB view details)

Uploaded Python 3macOS 10.12+ x86-64

File details

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

File metadata

  • Download URL: apiwright-0.1.0rc5-py3-none-win_arm64.whl
  • Upload date:
  • Size: 1.6 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.0rc5-py3-none-win_arm64.whl
Algorithm Hash digest
SHA256 6868ee98f953e79b28716aab53e87710e2e7abecd974755e3d8f301564bfe57a
MD5 0f94349c5f1741c795e6a65a4c9a76f5
BLAKE2b-256 7576801b3dbff84cc77d14bfd962b8cff3e4398fcd6b6f5d1211f2329bd8e2e6

See more details on using hashes here.

File details

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

File metadata

  • Download URL: apiwright-0.1.0rc5-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.0rc5-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 6a1474833409fe2477d11aceb9d0a95c73c7b6112d3706640099a4eeed1e4c42
MD5 e907af2bb8f40e55791934201970f08c
BLAKE2b-256 15772612f9ce27d94c90b901da65ad3844b069302a364a7c83968d6ed84ff628

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for apiwright-0.1.0rc5-py3-none-musllinux_1_2_x86_64.whl
Algorithm Hash digest
SHA256 ff044935f06c62553e3de72f368dd3fdcb1a45bfca80475d519f10e43941a582
MD5 d534689c2802da838b8e4b89d565079e
BLAKE2b-256 d2a148578db50797c9bd515b859a83389151537937366385e64a61aa23b95514

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for apiwright-0.1.0rc5-py3-none-musllinux_1_2_aarch64.whl
Algorithm Hash digest
SHA256 76d24fda3d798a8ddeddf88f5b5cddc07b230a83548faf86a02b99a1a93c028e
MD5 691836ae6451add7c34f5e70dfaf7f7e
BLAKE2b-256 7508a622fb588a00849f58df2d06152324eac4e9e74850cf1a66c89045b67a4a

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for apiwright-0.1.0rc5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 6de61eb34898c4647e3d9a3948ece020533e5e86598e7d5aaaa288aa579181c3
MD5 d889496c103b2925333913db6287bfae
BLAKE2b-256 9dcfb220bd120531fe6d6011090502e29e1a7097c651be794d2d7e28834248f3

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for apiwright-0.1.0rc5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 73dc13159db4222ca79add26a218f376f66e54a8fdbac8088d58d73286d1a061
MD5 a1958482c3ff19714f8bcd8495027911
BLAKE2b-256 19fbd20c36aadd17c7bfb410b9d0bb5aeaa58bb7220e9195a5e9e0f1ba94478e

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for apiwright-0.1.0rc5-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 602ca6bf3eedebe1fb3981da137d7ae6b7e1941f85542fc9591899c46cdd2402
MD5 79742f05fb0ca39bf15f4fc41db1b507
BLAKE2b-256 64804d42babc97f512736d7960fd8ff1b43430cb4a284a874826daeb8eedce1b

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for apiwright-0.1.0rc5-py3-none-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 675f10197e8bf7fe1bb9363002b2dc867731e740e59d136abb8c64213740d7cc
MD5 4d6bc9090a53e2c616729b1a82d0f817
BLAKE2b-256 2d1f525c0090ff58485161e5ea5e3909d1a14983983b65239eaef4a471e71956

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0rc5 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