|
| 1 | +# Tier 3 — OpenAPI client codegen (TS + Python) — design spec |
| 2 | + |
| 3 | +**Status:** Approved — ready for implementation |
| 4 | +**Date:** 2026-04-18 |
| 5 | +**Scope:** Ship `hawkapi gen-client` — a CLI + library that generates zero-runtime-dependency client SDKs (TypeScript + Python) from a HawkAPI app's OpenAPI 3.1 spec. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Goal |
| 10 | + |
| 11 | +```bash |
| 12 | +# From live app |
| 13 | +hawkapi gen-client python --app myapp.main:app --out ./clients/python/ |
| 14 | +hawkapi gen-client typescript --app myapp.main:app --out ./clients/ts/ |
| 15 | + |
| 16 | +# From exported spec |
| 17 | +hawkapi gen-client python --spec openapi.json --out ./clients/python/ |
| 18 | +``` |
| 19 | + |
| 20 | +Generated output: |
| 21 | + |
| 22 | +- **Python** (`client.py`) — msgspec Structs for all schemas, `Client` class with async methods backed by `httpx.AsyncClient`, typed responses, `ApiError` exception. |
| 23 | +- **TypeScript** (`client.ts`) — `interface`/`type` declarations for schemas, `Client` class with async methods backed by native `fetch`, typed responses, `ApiError` class. |
| 24 | + |
| 25 | +Both outputs are **single-file**, **zero-dep-at-generation-time** (only the generated file's runtime deps — httpx/msgspec for Python, fetch for TS), pass `mypy --strict` / `tsc --strict`. |
| 26 | + |
| 27 | +## Why this matters |
| 28 | + |
| 29 | +FastAPI users reach for `openapi-generator-cli` (Java/Node dependency) and get bloated clients with poor types and no async. A framework-native `gen-client` eliminates that tool from their pipeline. |
| 30 | + |
| 31 | +## Architecture |
| 32 | + |
| 33 | +Single-input / multi-output pipeline: |
| 34 | + |
| 35 | +``` |
| 36 | +OpenAPI 3.1 dict ──▶ parser.py (ClientIR) ──┬─▶ python.py ──▶ client.py |
| 37 | + └─▶ typescript.py ──▶ client.ts |
| 38 | +``` |
| 39 | + |
| 40 | +**`ClientIR`** (intermediate representation) — a small, language-agnostic dataclass tree: |
| 41 | + |
| 42 | +```python |
| 43 | +@dataclass(frozen=True, slots=True) |
| 44 | +class ClientIR: |
| 45 | + title: str |
| 46 | + version: str |
| 47 | + base_url: str | None |
| 48 | + schemas: tuple[SchemaIR, ...] |
| 49 | + operations: tuple[OperationIR, ...] |
| 50 | +``` |
| 51 | + |
| 52 | +(Full shape — `OperationIR`, `SchemaIR`, `FieldIR`, `ParamIR` — in `src/hawkapi/openapi/codegen/ir.py`.) |
| 53 | + |
| 54 | +Renderers consume `ClientIR` and emit strings. No jinja2 — pure Python string building keeps the renderer debuggable and the build dependency graph minimal. |
| 55 | + |
| 56 | +## Components |
| 57 | + |
| 58 | +### `src/hawkapi/openapi/codegen/parser.py` |
| 59 | + |
| 60 | +`build_client_ir(spec: dict) -> ClientIR` — reads an OpenAPI 3.1 dict and produces `ClientIR`. |
| 61 | + |
| 62 | +Handles: |
| 63 | +- `components.schemas` → `SchemaIR` tree, `$ref` resolution within the spec. |
| 64 | +- Each `paths.*.(get|post|put|patch|delete)` → `OperationIR`. |
| 65 | +- `servers[0].url` → `base_url` hint (nullable). |
| 66 | +- `operationId` → method name (fallback: `METHOD_path_slug` with non-alphanumeric chars replaced by `_`). |
| 67 | + |
| 68 | +### `src/hawkapi/openapi/codegen/python.py` |
| 69 | + |
| 70 | +`generate_python_client(ir: ClientIR) -> str` — produces a single `.py` file string with: |
| 71 | + |
| 72 | +- `ApiError(Exception)` with `status_code` + `detail`. |
| 73 | +- `Client` class: ctor `(base_url, *, headers=None, client=None)`; owns `httpx.AsyncClient` if none passed; `aclose()` closes only if owned. |
| 74 | +- One method per operation. Path params positional, others keyword-only. |
| 75 | +- `response_type` decoded via `msgspec.convert(r.json(), type=T)`; errors raise `ApiError`. |
| 76 | +- Body (if present) serialised via `msgspec.json.encode(body)` → `httpx` `content=`. |
| 77 | +- `None` for optional query params → skipped from dict. |
| 78 | + |
| 79 | +### `src/hawkapi/openapi/codegen/typescript.py` |
| 80 | + |
| 81 | +`generate_typescript_client(ir: ClientIR) -> str` — produces a single `.ts` file string with: |
| 82 | + |
| 83 | +- `interface` per schema Struct; union types for enums; direct type aliases for arrays/primitives. |
| 84 | +- `Client` class: ctor `({ baseUrl, headers?, fetch? })`; uses `globalThis.fetch` if not provided. |
| 85 | +- Method names are lowerCamelCase of `operationId`. |
| 86 | +- Path/query/header params bundled into one `params?` object. |
| 87 | +- Body goes in `body?` (JSON-stringified). |
| 88 | +- `ApiError extends Error` with `status` + `detail`. |
| 89 | +- TS 4.5+ (no ESM-only features beyond `import type`). |
| 90 | + |
| 91 | +### `src/hawkapi/cli.py` — new `gen-client` subcommand |
| 92 | + |
| 93 | +Parser: |
| 94 | + |
| 95 | +```python |
| 96 | +sub = subparsers.add_parser("gen-client") |
| 97 | +sub.add_argument("language", choices=["python", "typescript"]) |
| 98 | +group = sub.add_mutually_exclusive_group(required=True) |
| 99 | +group.add_argument("--app", help="module:attr of the HawkAPI instance") |
| 100 | +group.add_argument("--spec", help="path to openapi.json") |
| 101 | +sub.add_argument("--out", required=True, help="output directory") |
| 102 | +``` |
| 103 | + |
| 104 | +Dispatcher loads either `generate_openapi(app)` (via `--app`) or `json.load(open(spec))` (via `--spec`), passes to `build_client_ir`, renders, writes `client.py` or `client.ts`, prints output path. |
| 105 | + |
| 106 | +### Tests (`tests/unit/test_codegen.py`) |
| 107 | + |
| 108 | +- `build_client_ir` on a minimal spec → expected IR (deep-eq). |
| 109 | +- Python renderer: generate, parse with `ast.parse` (must be valid Python). |
| 110 | +- Python renderer: compile + exec into a namespace; instantiate `Client`; monkey-patch a fake `httpx.AsyncClient`; call a method; assert on request shape. |
| 111 | +- TS renderer: generate, parse with regex that the file has expected shape (robust across runners). |
| 112 | +- CLI: `hawkapi gen-client python --spec fixtures/minimal.json --out ./tmp/` produces a file; re-import succeeds. |
| 113 | +- Schema coverage: path param, query param, body (Struct), response (Struct), list response, optional query, enum, nested schemas. |
| 114 | + |
| 115 | +### Docs (`docs/guide/client-codegen.md`) |
| 116 | + |
| 117 | +Brief guide: when to use, CLI invocation, generated client usage examples (Python + TS), regeneration workflow (CI integration suggestion: regen on release). |
| 118 | + |
| 119 | +### Mkdocs nav + CHANGELOG |
| 120 | + |
| 121 | +- `mkdocs.yml`: new `Client codegen` entry in Guide (after Benchmarks). |
| 122 | +- `CHANGELOG.md`: one `[Unreleased] ### Added` bullet. |
| 123 | + |
| 124 | +## Out of scope |
| 125 | + |
| 126 | +- **Multi-file package output** (`setup.py`, `package.json`, README). v2. |
| 127 | +- **Go / Rust / Java clients.** v2 (would need a more generic IR + jinja2 templates). |
| 128 | +- **OAuth2 auto-injection**, cookie-auth, CSRF dance — user passes `headers` / cookies to the Client ctor. |
| 129 | +- **Streaming responses**, **WebSocket endpoints** — OpenAPI doesn't describe them well. |
| 130 | +- **Pagination helpers** (auto-iterating over `Page[T]`). v2. |
| 131 | +- **Versioning — regenerate on schema change detection** (would need a content hash + warning). v2. |
| 132 | +- **Pydantic support** in generated Python client — msgspec-only for v1. |
| 133 | + |
| 134 | +## Success criteria |
| 135 | + |
| 136 | +1. `hawkapi gen-client python --app demo:app --out py_client/` creates a working `client.py`. |
| 137 | +2. `hawkapi gen-client typescript --app demo:app --out ts_client/` creates a working `client.ts`. |
| 138 | +3. Generated Python client parses cleanly with `ast.parse`. |
| 139 | +4. Generated TS client matches the expected shape (smoke-regex tests). |
| 140 | +5. Unit-tests: 10+ cases covering ref-resolution, every operation shape, and both renderers. |
| 141 | +6. `docs/guide/client-codegen.md` in nav; mkdocs strict build clean. |
| 142 | + |
| 143 | +## Files touched |
| 144 | + |
| 145 | +- `src/hawkapi/openapi/codegen/__init__.py` — public API |
| 146 | +- `src/hawkapi/openapi/codegen/ir.py` — dataclasses (ClientIR, OperationIR, SchemaIR, FieldIR, ParamIR) |
| 147 | +- `src/hawkapi/openapi/codegen/parser.py` — OpenAPI → `ClientIR` |
| 148 | +- `src/hawkapi/openapi/codegen/python.py` — renderer |
| 149 | +- `src/hawkapi/openapi/codegen/typescript.py` — renderer |
| 150 | +- `src/hawkapi/cli.py` — `gen-client` subcommand + dispatcher |
| 151 | +- `tests/unit/test_codegen.py` — new |
| 152 | +- `docs/guide/client-codegen.md` — new |
| 153 | +- `mkdocs.yml` — nav entry |
| 154 | +- `CHANGELOG.md` — bullet |
| 155 | + |
| 156 | +## Rollback |
| 157 | + |
| 158 | +New module tree + new CLI subcommand + new docs. No existing code paths change. Revert is squash-safe: delete `codegen/`, remove the CLI subcommand branch, revert docs/changelog diffs. |
0 commit comments