Skip to content

Commit bb512cc

Browse files
Berik AshimovBerik Ashimov
authored andcommitted
docs: tier 3 OpenAPI codegen spec — hawkapi gen-client (TS + Python)
1 parent 94b4da1 commit bb512cc

1 file changed

Lines changed: 158 additions & 0 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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

Comments
 (0)