Skip to content

re #22 add SDK emitters (typed TypeScript + Python clients from OpenAPI)#91

Merged
tonydspaniard merged 1 commit into
masterfrom
feat/22-sdk-emitters
May 27, 2026
Merged

re #22 add SDK emitters (typed TypeScript + Python clients from OpenAPI)#91
tonydspaniard merged 1 commit into
masterfrom
feat/22-sdk-emitters

Conversation

@tonydspaniard
Copy link
Copy Markdown
Member

Closes #22. This is the last Phase-1 issue — Phase 1 is complete.

Summary

bin/altair spec:emit-sdk typescript|python turns the merged OpenAPI 3.1 document into a production-quality, idiomatic client SDK. No external code-gen runtime — no Java, no openapi-generator. The document is parsed with symfony/yaml (already a scaffold dep) into a language-neutral model, then each emitter walks that model.

The framework's pitch — "one spec, one consistent API for humans, clients, and AI" — now covers client SDKs too.

CLI

bin/altair spec:emit-sdk typescript > sdk.ts
bin/altair spec:emit-sdk typescript --out=clients/ts --multi-file
bin/altair spec:emit-sdk python --out=clients/python
bin/altair spec:emit-sdk --list
bin/altair spec:emit-sdk typescript --out=sdk.ts --check     # CI drift gate, exit 1 on drift
bin/altair spec:emit-sdk python --openapi=docs/openapi.yaml  # explicit input

Input defaults to merging docs/openapi/*.yaml fragments (same merge as spec:emit-openapi); --openapi=<path> overrides.

TypeScript output

  • fetch-based, zero runtime dependencies
  • interface for object shapes, type for unions
  • status-discriminated response unions: { status: 201; data: ... } | { status: 422; data: ... }
  • one tree-shakeable exported async function per operation
  • ApiOptions type, ApiError class, private request() helper
  • path params → typed args + template-literal URL

Python output

  • httpx + pydantic v2
  • str, Enum for enums; BaseModel per object schema
  • snake_case fields with Field(alias=...) for wire-name mapping
  • both Client (sync) and AsyncClient

Architecture

src/Altair/Scaffold/Sdk/
├── Model/{OpenApiParser, OpenApiDocument, OperationModel, ResponseModel, SchemaType}
├── Contracts/EmitterInterface
├── EmitterRegistry  ·  EmittedSdk  ·  Exception/SdkException
├── TypeScript/TypeScriptEmitter
└── Python/PythonEmitter

CLI: Cli/EmitSdkCommand. Add a language by implementing EmitterInterface + registering it — no need to fork the existing emitters.

Parser handles inline schemas, $ref, components.schemas, enums, and OpenAPI-3.1 type: [x, "null"] nullability. operationId is honoured when present; synthesised otherwise (POST /userscreateUser singularised, GET /users/{id}getUsersById).

Determinism

Named types alpha-sorted, operations in document order → byte-stable output. --check is therefore a reliable CI drift gate.

Tests (32, 2 skipped)

  • OpenApiParserTest — title/version/operations, components + enum, request body + path params, response union, operationId synthesis, non-map rejection
  • TypeScriptEmitterTest / PythonEmitterTest — golden-shape assertions per language feature, determinism, multi-file split
  • EmitterRegistryTest — get / has / available / case-insensitive / unknown-throws
  • EmitSdkCommandTest--list, unknown language, stdout, file + --check pass, drift detection, multi-file
  • CompileIntegrationTest — runs real tsc / mypy against emitted output; skipped when the toolchain is absent (same pattern as the ext-redis / ext-mongodb storage tests, so the framework suite stays green without Node/Python)

Test plan

  • composer test --filter 'Altair\\Tests\\Scaffold\\Sdk\\\\'32 tests, 81 assertions, 0 failures, 2 skipped
  • composer cs — clean
  • composer stan — clean
  • composer rector — clean (fixpoint)
  • Golden-shape per operation pattern: GET, POST, path-parameterised, multi-status error responses
  • --check exits non-zero on drift, zero when in sync

Decisions / notes

  • No cebe/php-openapi. The issue assumed it was already a dep (it wasn't). The framework's own OpenAPI is plain JSON-schema-shaped YAML, so symfony/yaml (already present) is sufficient — keeps the dependency footprint minimal.
  • tsc/mypy not added to CI here. The compile test is skip-on-absent; wiring Node + Python toolchains into .github/workflows/ci.yml to un-skip it is a deliberate follow-up, not bundled into this PR.
  • A stray duplicate parser (Sdk/Parser/OpenApiParser.php) was found mid-build and, per discussion, merged into Sdk/Model/OpenApiParser.php (kept its singularisation + richer operationId synthesis; kept my YAML/$ref/enum/error handling), then removed.

Out of scope (follow-ups, per issue)

  • Go / Rust / Swift / Kotlin emitters (same EmitterInterface structure)
  • Mock-server emission, Postman/Bruno collections, Stoplight Elements
  • CI toolchain wiring to un-skip CompileIntegrationTest

`bin/altair spec:emit-sdk typescript|python` turns the merged OpenAPI
3.1 document into a production-quality, idiomatic client SDK. No
external code-gen runtime (no Java / openapi-generator) — the document
is parsed with symfony/yaml (already a dep) into a language-neutral
model, and each language emitter walks that model.

## What ships (src/Altair/Scaffold/Sdk/)

- Model/ — OpenApiParser + OpenApiDocument / OperationModel /
  ResponseModel / SchemaType (handles inline schemas, $ref,
  components.schemas, enums, OpenAPI-3.1 `type: [x, null]` nullability)
- Contracts/EmitterInterface, EmitterRegistry (language → emitter),
  EmittedSdk value object, Exception/SdkException
- TypeScript/TypeScriptEmitter — fetch-based, zero runtime deps,
  `interface` for objects + `type` for unions, status-discriminated
  response unions, one tree-shakeable exported function per operation,
  ApiOptions + ApiError + private request() helper
- Python/PythonEmitter — httpx + pydantic v2, str/Enum for enums,
  snake_case fields with Field(alias=...) for wire mapping, sync
  Client + AsyncClient
- Cli/EmitSdkCommand — `<language>`, `--list`, `--out`, `--multi-file`,
  `--check` (CI drift gate), `--openapi=<path>` (else merges
  docs/openapi/*.yaml)

operationId synthesis singularises POST-to-collection
(`POST /users` → createUser) and handles path params
(`GET /users/{id}` → getUsersById).

## Determinism

Named types alpha-sorted, operations in document order → byte-stable
output, so `--check` is a reliable drift gate for CI.

## Tests (32, 2 skipped)

- OpenApiParser: title/version/operations, components+enum, request
  body + path params, response union, operationId synthesis, non-map
  rejection
- TypeScript + Python emitters: golden-shape assertions per language
  feature + determinism + multi-file split
- EmitterRegistry: get/has/available/case-insensitive/unknown-throws
- EmitSdkCommand: --list, unknown lang, stdout, file + --check pass,
  drift detection, multi-file
- CompileIntegrationTest: runs real tsc/mypy against emitted output;
  SKIPPED when the toolchain is absent (matches ext-redis pattern)

## Docs

- .agent/packages/scaffold.md SDK section
- AGENT.md §2 scaffold entry
- CLAUDE.md §3 "Client SDK emitters" subsection

No new composer deps — symfony/yaml was already a scaffold dependency.

Closes Phase 1.
@tonydspaniard tonydspaniard merged commit 321982a into master May 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SDK emitters — TypeScript + Python clients from OpenAPI

1 participant