Skip to content

SDK emitters — TypeScript + Python clients from OpenAPI #22

@tonydspaniard

Description

@tonydspaniard

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:

  1. openapi-generator-cli requires Java. Putting Java in a PHP framework's install path is hostile.
  2. 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

  • bin/altair spec emit-sdk typescript > sdk.ts produces a file that:
    • Compiles under tsc --strict with zero errors
    • Has no runtime dependencies (only fetch)
    • Provides typed responses including the status-code-discriminated union pattern
  • bin/altair spec emit-sdk python > client.py produces a file that:
    • Passes mypy --strict
    • Imports without error in Python 3.11+
    • Provides both sync and async client classes
    • Models use Pydantic v2
  • Both emitters are deterministic (re-running produces byte-identical output)
  • --check mode diffs regenerated content vs. disk; non-zero exit on drift; CI can enforce
  • Tests:
    • Golden-file tests: known OpenAPI fragment → expected TS output (one per operation pattern: GET, POST, path-parameterized, error responses)
    • Same for Python
    • End-to-end: scaffolder generates spec → emit SDK → spawn tsc/mypy in subprocess → exit code 0
    • The integration test ships fixture OpenAPI files; CI runs them through both emitters and verifies the output compiles

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions