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.
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.
--forceoverrides 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.pyruns against the Python packagecorpus/<name>.checks.tsis copied into the package'stests/, sotscchecks 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
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file apiwright-0.1.0rc3-py3-none-win_arm64.whl.
File metadata
- Download URL: apiwright-0.1.0rc3-py3-none-win_arm64.whl
- Upload date:
- Size: 1.6 MB
- Tags: Python 3, Windows ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fff3b2c031d083b34fe438f9aef96fccafb8b7297245ab508aca889ffe63f7c1
|
|
| MD5 |
bf2e61f42dbf7dc15d96770f01800faa
|
|
| BLAKE2b-256 |
027e3dfcf2b9d1ac64a337657195c93f31bdd7f73aad2645e70c14aed7f9001e
|
File details
Details for the file apiwright-0.1.0rc3-py3-none-win_amd64.whl.
File metadata
- Download URL: apiwright-0.1.0rc3-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/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
99657af348287d2dd6cdeb8ab696f92f4a7c1c5a9ce1bff8b673e1368852071d
|
|
| MD5 |
ede307e441ab3d0737dd26550bf608c4
|
|
| BLAKE2b-256 |
c10a521ebd11df462a19ae40b9f83570e4f02a21825609f2a66aa490dff7cb51
|
File details
Details for the file apiwright-0.1.0rc3-py3-none-musllinux_1_2_x86_64.whl.
File metadata
- Download URL: apiwright-0.1.0rc3-py3-none-musllinux_1_2_x86_64.whl
- Upload date:
- Size: 1.9 MB
- Tags: Python 3, musllinux: musl 1.2+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cfd841b46b8287438716558a35c6272ad1531481ac61b302541a69aad9499ecc
|
|
| MD5 |
c2685e4c7195b00859823589bee07575
|
|
| BLAKE2b-256 |
f71a8336fb89489cf5eb5c76c0e7f7f3abfc905132ae80dfa4a1ee5c5d71055a
|
File details
Details for the file apiwright-0.1.0rc3-py3-none-musllinux_1_2_aarch64.whl.
File metadata
- Download URL: apiwright-0.1.0rc3-py3-none-musllinux_1_2_aarch64.whl
- Upload date:
- Size: 1.7 MB
- Tags: Python 3, musllinux: musl 1.2+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ed0cb95eabdc82483ee8fa4254f8bcfe2107bf4cb315eef140a4089dd3f482c0
|
|
| MD5 |
75d0e21d35d713ab6ec2ac5abb716abb
|
|
| BLAKE2b-256 |
097ee9b11d4f9da38b7c5ac02f0301580b488f024d3347d22e04a9643fed7186
|
File details
Details for the file apiwright-0.1.0rc3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: apiwright-0.1.0rc3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 1.8 MB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1c9a4c465998d650e68799c2e7feeeb3c8f633d413b0c10f1c1c88f30406b20f
|
|
| MD5 |
c738b0dedab66493d09548c46271fe1a
|
|
| BLAKE2b-256 |
fb3df7a62f6d04e90368787ae8d4ea908016d089c76a8880e828c942f8be57c0
|
File details
Details for the file apiwright-0.1.0rc3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: apiwright-0.1.0rc3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 1.7 MB
- Tags: Python 3, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b6b8cf29ee90f95d2a4395162c034f144b966f74c59c2e20e36539fcebcd114b
|
|
| MD5 |
c560c1e11ae0b6d6e58ceda2062e7a76
|
|
| BLAKE2b-256 |
fe0ba8466c82159437d93cb1110d99776a4e3926d96e75b1204ca27216ebe772
|
File details
Details for the file apiwright-0.1.0rc3-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: apiwright-0.1.0rc3-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 1.6 MB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1eeff871d9b38c72506480f3081f0a97ef06e751aac8281916379275b4fa6870
|
|
| MD5 |
60818810be36930576a3811a18abefc2
|
|
| BLAKE2b-256 |
872f248f5c92a2e40fe8697f574cb83d5bf54788ff0c22908f811e920ed19069
|
File details
Details for the file apiwright-0.1.0rc3-py3-none-macosx_10_12_x86_64.whl.
File metadata
- Download URL: apiwright-0.1.0rc3-py3-none-macosx_10_12_x86_64.whl
- Upload date:
- Size: 1.7 MB
- Tags: Python 3, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d165127a0362747d9258ef6caa4461b28a25c75ac4591b554d35a725df918474
|
|
| MD5 |
e81abbcbe93f00f4a2634a78b9d84ac4
|
|
| BLAKE2b-256 |
0e5af92f3bbba58ee1a637a37e43e3cd2a843125e5be83b59e478a5a98e5e0ff
|