Metadata-Version: 2.4
Name: apiwright
Version: 0.1.0rc6
Summary: OpenAPI 3.1 client generator for Python and TypeScript
Home-Page: https://github.com/RichardDRJ/apiwright
Author: Richard Rae-Jones
License: MIT OR Apache-2.0
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Homepage, https://github.com/RichardDRJ/apiwright

# 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

```sh
apiwright python -i openapi.yaml -o generated
```

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

```sh
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:

```yaml
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.

```toml
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:

```toml
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:

```toml
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 `operationId`s before they become method names.
Rules run in order.

```toml
[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.

```sh
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:

```sh
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 `operationId`s,
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.

