Goal
Extend the scaffolder (#3) with SDK emitters that take the OpenAPI 3.1 document (generated by bin/altair spec emit-openapi) and produce typed client SDKs in TypeScript and Python. The emitted SDKs should be production-quality: fully typed, ergonomic, no external code-gen runtime required.
Why
The framework's pitch is "one spec, one consistent API for humans, clients, and AI." Half of "clients" is client SDKs. Today PHP teams either:
- Hand-write the TypeScript/Python client (drift, double-maintenance)
- Use
openapi-generator-cli (Java, 600MB Docker image, awkward output)
- Skip it and let the frontend infer types from the API (unsafe)
A first-party SDK emitter that produces idiomatic output for the two most common languages is a deeply differentiating feature, and once the OpenAPI emitter exists (in #3) the marginal cost of adding it is small.
Scope
This issue covers TypeScript and Python. Each language gets its own emitter under src/Altair/Scaffold/Sdk/. Other languages (Go, Rust, Swift, Kotlin) can be follow-ups, structured the same way.
TypeScript output target
- Single-file output (
sdk.ts) by default; multi-file (sdk/) opt-in via --multi-file
fetch-based, zero runtime dependencies (no Axios)
- Strict TypeScript (
strict: true compatible)
- Generated types use
interface for object shapes (extensible), type for unions
- Discriminated unions for response status (
Result<User> | ErrorResponse)
- Auto-included auth header types
- Tree-shakeable: each operation exported as a separate function
Example emitted shape:
// generated, do not edit. spec sha: 4f3a...
export interface User {
id: string;
email: string;
role: UserRole;
createdAt: string; // ISO 8601
}
export type UserRole = 'admin' | 'member' | 'viewer';
export interface CreateUserRequest {
email: string;
password: string;
role?: UserRole;
}
export interface CreateUserError {
errors: Record<string, string[]>;
}
export type CreateUserResponse =
| { status: 201; data: { user: User } }
| { status: 422; data: CreateUserError }
| { status: 409; data: { message: string } };
export async function createUser(
body: CreateUserRequest,
options: ApiOptions = {},
): Promise<CreateUserResponse> {
return request('POST', '/users', { body, ...options });
}
Generic helpers: an ApiOptions type (baseUrl, headers, signal, fetch), an ApiError class for transport failures, a request() private helper.
Python output target
- Single-file output by default; multi-module opt-in
- Built on
httpx (modern, async-capable, well-typed) — added as the SDK's sole runtime dep
pydantic v2 models for request/response shapes (gives free runtime validation + IDE support)
- Sync + async client classes (
UsersClient + AsyncUsersClient) since httpx supports both
- Type hints throughout; passes
mypy --strict
Example emitted shape:
# generated, do not edit. spec sha: 4f3a...
from datetime import datetime
from enum import Enum
from typing import Literal
from pydantic import BaseModel
import httpx
class UserRole(str, Enum):
ADMIN = "admin"
MEMBER = "member"
VIEWER = "viewer"
class User(BaseModel):
id: str
email: str
role: UserRole
created_at: datetime
class CreateUserRequest(BaseModel):
email: str
password: str
role: UserRole | None = None
class CreateUserError(BaseModel):
errors: dict[str, list[str]]
class UsersClient:
def __init__(self, base_url: str, client: httpx.Client | None = None) -> None:
self._client = client or httpx.Client(base_url=base_url)
def create_user(self, body: CreateUserRequest) -> User | CreateUserError | dict:
r = self._client.post("/users", json=body.model_dump(by_alias=True, exclude_none=True))
if r.status_code == 201:
return User.model_validate(r.json()["user"])
if r.status_code == 422:
return CreateUserError.model_validate(r.json())
return r.json()
CLI surface (extends #3's spec group)
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 > client.py
bin/altair spec emit-sdk python --out=clients/python --multi-file
bin/altair spec emit-sdk --list # what languages are available
bin/altair spec emit-sdk typescript --check # emit, diff against existing, exit non-zero on drift
Build strategy: emit ourselves vs. wrap openapi-generator
We emit ourselves. Two reasons:
openapi-generator-cli requires Java. Putting Java in a PHP framework's install path is hostile.
- The output quality of
openapi-generator-cli is mediocre. Lots of dead options, lots of "you can configure this with these 40 flags". We want opinionated, idiomatic output, not configurable mediocre output.
Implementation: read OpenAPI 3.1 via cebe/php-openapi (already a dep from #3), template emit per language. Each emitter is a few hundred lines.
Shape (extension of univeros/scaffold)
src/Altair/Scaffold/Sdk/
├── EmitterInterface.php
├── TypeScript/
│ ├── TypeScriptEmitter.php
│ └── templates/
│ ├── root.ts.template
│ ├── operation.ts.template
│ └── model.ts.template
├── Python/
│ ├── PythonEmitter.php
│ └── templates/
│ ├── root.py.template
│ ├── operation.py.template
│ └── model.py.template
└── EmitterRegistry.php # language -> emitter lookup
EmitSdkCommand in Altair\Scaffold\Cli\ (from #3) dispatches to the right emitter.
Naming conventions
- TS: camelCase fields, PascalCase types
- Python: snake_case fields, PascalCase classes — Pydantic
Field(alias="...") for JSON ↔ Python mapping
- Spec drives the wire format; the SDK adapts to the language's idiom
Acceptance criteria
Out of scope
- Go, Rust, Swift, Kotlin SDKs (follow-up issues, same structure)
- OpenAPI 2.0 (Swagger) input — we only consume our own 3.1 emission
- WebSocket / streaming endpoints (REST-only first cut)
- GraphQL clients
- Mock server emission (a separate, complementary feature)
Dependencies
No new external composer deps — re-uses cebe/php-openapi from #3. The emitted SDKs themselves have their own deps (httpx for Python, none for TS) but those are the consumer's deps, not the framework's.
Stretch goals (for follow-ups)
- Mock server emission:
bin/altair spec emit-mock-server > mock.ts → Express/Fastify server that returns spec-conformant fixtures, for frontend dev without backend running
- Postman / Bruno collection emission
- Stoplight Elements doc site emission
Goal
Extend the scaffolder (#3) with SDK emitters that take the OpenAPI 3.1 document (generated by
bin/altair spec emit-openapi) and produce typed client SDKs in TypeScript and Python. The emitted SDKs should be production-quality: fully typed, ergonomic, no external code-gen runtime required.Why
The framework's pitch is "one spec, one consistent API for humans, clients, and AI." Half of "clients" is client SDKs. Today PHP teams either:
openapi-generator-cli(Java, 600MB Docker image, awkward output)A first-party SDK emitter that produces idiomatic output for the two most common languages is a deeply differentiating feature, and once the OpenAPI emitter exists (in #3) the marginal cost of adding it is small.
Scope
This issue covers TypeScript and Python. Each language gets its own emitter under
src/Altair/Scaffold/Sdk/. Other languages (Go, Rust, Swift, Kotlin) can be follow-ups, structured the same way.TypeScript output target
sdk.ts) by default; multi-file (sdk/) opt-in via--multi-filefetch-based, zero runtime dependencies (no Axios)strict: truecompatible)interfacefor object shapes (extensible),typefor unionsResult<User> | ErrorResponse)Example emitted shape:
Generic helpers: an
ApiOptionstype (baseUrl,headers,signal,fetch), anApiErrorclass for transport failures, arequest()private helper.Python output target
httpx(modern, async-capable, well-typed) — added as the SDK's sole runtime deppydantic v2models for request/response shapes (gives free runtime validation + IDE support)UsersClient+AsyncUsersClient) since httpx supports bothmypy --strictExample emitted shape:
CLI surface (extends #3's
specgroup)Build strategy: emit ourselves vs. wrap openapi-generator
We emit ourselves. Two reasons:
openapi-generator-clirequires Java. Putting Java in a PHP framework's install path is hostile.openapi-generator-cliis mediocre. Lots of dead options, lots of "you can configure this with these 40 flags". We want opinionated, idiomatic output, not configurable mediocre output.Implementation: read OpenAPI 3.1 via
cebe/php-openapi(already a dep from #3), template emit per language. Each emitter is a few hundred lines.Shape (extension of
univeros/scaffold)EmitSdkCommandinAltair\Scaffold\Cli\(from #3) dispatches to the right emitter.Naming conventions
Field(alias="...")for JSON ↔ Python mappingAcceptance criteria
bin/altair spec emit-sdk typescript > sdk.tsproduces a file that:tsc --strictwith zero errorsfetch)bin/altair spec emit-sdk python > client.pyproduces a file that:mypy --strict--checkmode diffs regenerated content vs. disk; non-zero exit on drift; CI can enforcetsc/mypyin subprocess → exit code 0Out of scope
Dependencies
univeros/cli)univeros/scaffold) — required (OpenAPI emission is the input)No new external composer deps — re-uses
cebe/php-openapifrom #3. The emitted SDKs themselves have their own deps (httpxfor Python, none for TS) but those are the consumer's deps, not the framework's.Stretch goals (for follow-ups)
bin/altair spec emit-mock-server > mock.ts→ Express/Fastify server that returns spec-conformant fixtures, for frontend dev without backend running