From 5521117ce338ab75a85e34e813f37f1cebb9d851 Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Wed, 27 May 2026 18:18:09 +0200 Subject: [PATCH] =?UTF-8?q?re=20#22=20add=20SDK=20emitters=20=E2=80=94=20t?= =?UTF-8?q?yped=20TypeScript=20+=20Python=20clients=20from=20OpenAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 — ``, `--list`, `--out`, `--multi-file`, `--check` (CI drift gate), `--openapi=` (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. --- .agent/packages/scaffold.md | 25 +- AGENT.md | 2 +- CLAUDE.md | 13 + src/Altair/Scaffold/Cli/EmitSdkCommand.php | 235 +++++++++++ .../Sdk/Contracts/EmitterInterface.php | 37 ++ src/Altair/Scaffold/Sdk/EmittedSdk.php | 42 ++ src/Altair/Scaffold/Sdk/EmitterRegistry.php | 69 ++++ .../Scaffold/Sdk/Exception/SdkException.php | 16 + .../Scaffold/Sdk/Model/OpenApiDocument.php | 32 ++ .../Scaffold/Sdk/Model/OpenApiParser.php | 373 ++++++++++++++++++ .../Scaffold/Sdk/Model/OperationModel.php | 50 +++ .../Scaffold/Sdk/Model/ResponseModel.php | 41 ++ src/Altair/Scaffold/Sdk/Model/SchemaType.php | 92 +++++ .../Scaffold/Sdk/Python/PythonEmitter.php | 298 ++++++++++++++ .../Sdk/TypeScript/TypeScriptEmitter.php | 295 ++++++++++++++ tests/Scaffold/Sdk/CompileIntegrationTest.php | 91 +++++ tests/Scaffold/Sdk/EmitSdkCommandTest.php | 120 ++++++ tests/Scaffold/Sdk/EmitterRegistryTest.php | 42 ++ tests/Scaffold/Sdk/Fixtures/users-api.yaml | 65 +++ tests/Scaffold/Sdk/OpenApiParserTest.php | 96 +++++ tests/Scaffold/Sdk/PythonEmitterTest.php | 78 ++++ tests/Scaffold/Sdk/TypeScriptEmitterTest.php | 83 ++++ 22 files changed, 2193 insertions(+), 2 deletions(-) create mode 100644 src/Altair/Scaffold/Cli/EmitSdkCommand.php create mode 100644 src/Altair/Scaffold/Sdk/Contracts/EmitterInterface.php create mode 100644 src/Altair/Scaffold/Sdk/EmittedSdk.php create mode 100644 src/Altair/Scaffold/Sdk/EmitterRegistry.php create mode 100644 src/Altair/Scaffold/Sdk/Exception/SdkException.php create mode 100644 src/Altair/Scaffold/Sdk/Model/OpenApiDocument.php create mode 100644 src/Altair/Scaffold/Sdk/Model/OpenApiParser.php create mode 100644 src/Altair/Scaffold/Sdk/Model/OperationModel.php create mode 100644 src/Altair/Scaffold/Sdk/Model/ResponseModel.php create mode 100644 src/Altair/Scaffold/Sdk/Model/SchemaType.php create mode 100644 src/Altair/Scaffold/Sdk/Python/PythonEmitter.php create mode 100644 src/Altair/Scaffold/Sdk/TypeScript/TypeScriptEmitter.php create mode 100644 tests/Scaffold/Sdk/CompileIntegrationTest.php create mode 100644 tests/Scaffold/Sdk/EmitSdkCommandTest.php create mode 100644 tests/Scaffold/Sdk/EmitterRegistryTest.php create mode 100644 tests/Scaffold/Sdk/Fixtures/users-api.yaml create mode 100644 tests/Scaffold/Sdk/OpenApiParserTest.php create mode 100644 tests/Scaffold/Sdk/PythonEmitterTest.php create mode 100644 tests/Scaffold/Sdk/TypeScriptEmitterTest.php diff --git a/.agent/packages/scaffold.md b/.agent/packages/scaffold.md index b604b49d..2586c01f 100644 --- a/.agent/packages/scaffold.md +++ b/.agent/packages/scaffold.md @@ -1,6 +1,29 @@ # univeros/scaffold · Altair\Scaffold -**Purpose:** Spec-to-API code generator: YAML spec in, Action/Input/Responder + OpenAPI + tests out. Includes a **journal** sub-feature for rewindable/replayable scaffold operations. +**Purpose:** Spec-to-API code generator: YAML spec in, Action/Input/Responder + OpenAPI + tests out. Includes a **journal** sub-feature for rewindable/replayable scaffold operations and **SDK emitters** that turn the OpenAPI document into typed TypeScript / Python clients. + +## SDK emitters (issue #22) + +`bin/altair spec:emit-sdk ` reads the merged OpenAPI 3.1 document and emits a production-quality, idiomatic client SDK. No external code-gen runtime (no Java / openapi-generator) — the OpenAPI document is parsed with `symfony/yaml` (already a dep) into a language-neutral model, and each language emitter walks that model. + +```bash +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 # available languages +bin/altair spec:emit-sdk typescript --out=sdk.ts --check # exit 1 on drift (CI gate) +bin/altair spec:emit-sdk python --openapi=docs/openapi.yaml # explicit input doc +``` + +The document defaults to merging `docs/openapi/*.yaml` fragments (same merge as `spec:emit-openapi`); `--openapi=` overrides. + +**TypeScript output** (`Sdk\TypeScript\TypeScriptEmitter`): `fetch`-based, zero runtime deps, `interface` for objects + `type` for unions, status-discriminated response unions (`{ status: 201; data: ... } | { status: 422; data: ... }`), one tree-shakeable exported function per operation, plus `ApiOptions` / `ApiError` / a private `request()` helper. + +**Python output** (`Sdk\Python\PythonEmitter`): `httpx` + `pydantic v2`, `str, Enum` for enums, snake_case fields with `Field(alias=...)` for wire-name mapping, both `Client` (sync) and `AsyncClient`. + +Both are deterministic (named types alpha-sorted, operations in document order) so `--check` is a reliable CI drift gate. The `Sdk\EmitterRegistry` maps `language → emitter`; add Go/Rust/etc. by registering more `Sdk\Contracts\EmitterInterface` implementations. + +Model: `Sdk\Model\{OpenApiParser, OpenApiDocument, OperationModel, ResponseModel, SchemaType}`. CLI: `Cli\EmitSdkCommand`. ## Journal sub-feature (issue #72) diff --git a/AGENT.md b/AGENT.md index 44ea54a6..a113952e 100644 --- a/AGENT.md +++ b/AGENT.md @@ -43,7 +43,7 @@ This file is the source of truth. Tool-specific entry points (`CLAUDE.md`) point │ ├── Middleware/ ← PSR-15 middleware primitives │ ├── Persistence/ ← Repository/UnitOfWork over Cycle ORM v2 + migration CLI │ ├── Sanitation/ ← Input sanitation rules -│ ├── Scaffold/ ← YAML-spec-to-code generator (bin/altair spec:scaffold), with optional persistence: and queue: blocks; includes Journal sub-feature (journal:list/show/diff/rewind/replay) for rewindable scaffold operations +│ ├── Scaffold/ ← YAML-spec-to-code generator (bin/altair spec:scaffold), with optional persistence: and queue: blocks; Journal sub-feature (journal:*) for rewindable scaffold ops; SDK emitters (spec:emit-sdk typescript|python) for typed clients │ ├── Security/ ← Hashing, encryption, CSRF tokens │ ├── Session/ ← Session handlers (file, Redis, Mongo) │ ├── Structure/ ← Collection primitives (Map, Set, etc.) diff --git a/CLAUDE.md b/CLAUDE.md index 83568b19..7a7fc219 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,19 @@ bin/altair spec:lint # drift check When you add a new HTTP endpoint, write the YAML spec first and scaffold it — don't hand-write the Action/Input/Responder triple. After hand-editing generated files, run `bin/altair spec:lint` so drift surfaces in CI. +### Client SDK emitters + +`bin/altair spec:emit-sdk typescript|python` turns the merged OpenAPI 3.1 document into a typed client SDK — no external code-gen runtime (the doc is parsed with `symfony/yaml` into a neutral model under `Altair\Scaffold\Sdk\Model`, then each emitter walks it). + +```bash +bin/altair spec:emit-sdk typescript > sdk.ts # fetch-based, zero-dep, discriminated unions +bin/altair spec:emit-sdk python --out=clients/python # httpx + pydantic v2, sync + async clients +bin/altair spec:emit-sdk typescript --out=sdk.ts --check # CI drift gate (exit 1 on drift) +bin/altair spec:emit-sdk --list +``` + +Output is deterministic — wire it into CI with `--check` so the committed SDK can't drift from the spec. Don't hand-edit emitted SDKs; regenerate. To add a language, implement `Altair\Scaffold\Sdk\Contracts\EmitterInterface` and register it in `EmitterRegistry` — don't fork the TypeScript/Python emitters. + ### Persistence (Cycle ORM bridge) The `univeros/persistence` sub-package wraps Cycle ORM v2 behind framework-owned `RepositoryInterface` / `UnitOfWorkInterface` / `EntityManagerInterface` contracts. The host application binds a `SchemaProviderInterface` (either build-time pre-compiled or `AttributeSchemaProvider`), then `CycleOrmConfiguration` wires the rest from `DB_*` env vars. diff --git a/src/Altair/Scaffold/Cli/EmitSdkCommand.php b/src/Altair/Scaffold/Cli/EmitSdkCommand.php new file mode 100644 index 00000000..312cae0f --- /dev/null +++ b/src/Altair/Scaffold/Cli/EmitSdkCommand.php @@ -0,0 +1,235 @@ +` — generate a typed client SDK + * from the merged OpenAPI 3.1 document. + * + * ```bash + * 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 --check # exit 1 on drift + * ``` + * + * The OpenAPI document is read from `--openapi=` or, by default, + * produced on the fly by merging `docs/openapi/*.yaml` fragments (the + * same merge `spec:emit-openapi` performs). + */ +#[Command( + name: 'spec:emit-sdk', + description: 'Generate a typed client SDK (TypeScript / Python) from the OpenAPI document.', +)] +final readonly class EmitSdkCommand +{ + public function __construct( + private PathResolver $paths = new PathResolver(), + private OpenApiParser $parser = new OpenApiParser(), + ) {} + + public function __invoke( + #[Argument(description: 'Target language: typescript or python.')] + ?string $language = null, + #[Option(description: 'List available SDK languages and exit.')] + bool $list = false, + #[Option(description: 'Path to a merged OpenAPI document. Defaults to merging docs/openapi/*.yaml.')] + ?string $openapi = null, + #[Option(description: 'Write output to this directory (multi-file) or file. Omit to write to stdout.', name: 'out')] + ?string $out = null, + #[Option(description: 'Emit one file per concern instead of a single bundle.', name: 'multi-file')] + bool $multiFile = false, + #[Option(description: 'Compare regenerated output against files on disk; exit 1 on drift.')] + bool $check = false, + #[Option(description: 'Override the project root.')] + ?string $root = null, + ): int { + $registry = EmitterRegistry::default(); + + if ($list || $language === null) { + echo "Available SDK languages:\n"; + foreach ($registry->available() as $lang) { + echo ' - ' . $lang . "\n"; + } + + return 0; + } + + if (!$registry->has($language)) { + echo \sprintf("Unknown language '%s'. Available: %s.\n", $language, implode(', ', $registry->available())); + + return 2; + } + + try { + $document = $this->loadDocument($root, $openapi); + } catch (Throwable $throwable) { + echo 'Could not load OpenAPI document: ' . $throwable->getMessage() . "\n"; + + return 2; + } + + $emitter = $registry->get($language); + + try { + $emitted = $emitter->emit($document, $multiFile); + } catch (SdkException $sdkException) { + echo 'SDK emission failed: ' . $sdkException->getMessage() . "\n"; + + return 2; + } + + if ($check) { + return $this->runCheck($emitted->files, $out, $emitter->defaultFileName()); + } + + if ($out === null) { + echo $emitted->single(); + + return 0; + } + + return $this->writeFiles($emitted->files, $out, $multiFile, $emitter->defaultFileName()); + } + + /** + * @param array $files + */ + private function writeFiles(array $files, string $out, bool $multiFile, string $defaultFileName): int + { + if (!$multiFile && \count($files) === 1) { + // Single-file mode: `$out` is the target file path (or a dir). + $target = is_dir($out) ? rtrim($out, '/\\') . DIRECTORY_SEPARATOR . $defaultFileName : $out; + $this->ensureDir(\dirname($target)); + file_put_contents($target, reset($files)); + echo 'Wrote ' . $target . "\n"; + + return 0; + } + + foreach ($files as $relative => $contents) { + $target = rtrim($out, '/\\') . DIRECTORY_SEPARATOR . $relative; + $this->ensureDir(\dirname($target)); + file_put_contents($target, $contents); + echo 'Wrote ' . $target . "\n"; + } + + return 0; + } + + /** + * @param array $files + */ + private function runCheck(array $files, ?string $out, string $defaultFileName): int + { + $drift = []; + foreach ($files as $relative => $contents) { + $target = $this->checkTarget($out, $relative, $defaultFileName, \count($files)); + if ($target === null || !is_file($target)) { + $drift[] = $relative . ' (missing on disk)'; + continue; + } + + if ((string) file_get_contents($target) !== $contents) { + $drift[] = $relative . ' (differs)'; + } + } + + if ($drift === []) { + echo "SDK is up to date.\n"; + + return 0; + } + + echo "SDK drift detected:\n"; + foreach ($drift as $item) { + echo ' - ' . $item . "\n"; + } + + return 1; + } + + private function checkTarget(?string $out, string $relative, string $defaultFileName, int $fileCount): ?string + { + if ($out === null) { + return null; + } + + if ($fileCount === 1) { + return is_dir($out) ? rtrim($out, '/\\') . DIRECTORY_SEPARATOR . $defaultFileName : $out; + } + + return rtrim($out, '/\\') . DIRECTORY_SEPARATOR . $relative; + } + + private function loadDocument(?string $root, ?string $openapi): OpenApiDocument + { + if ($openapi !== null) { + if (!is_file($openapi)) { + throw new SdkException(\sprintf("OpenAPI file '%s' not found.", $openapi)); + } + + return $this->parser->parseYaml((string) file_get_contents($openapi)); + } + + $projectRoot = $this->paths->resolveProjectRoot($root); + $fragmentsDir = $projectRoot . DIRECTORY_SEPARATOR . 'docs' . DIRECTORY_SEPARATOR . 'openapi'; + if (!is_dir($fragmentsDir)) { + throw new SdkException(\sprintf("No OpenAPI document given and fragments dir '%s' does not exist.", $fragmentsDir)); + } + + return $this->parser->parseYaml($this->mergeFragments($fragmentsDir)); + } + + private function mergeFragments(string $directory): string + { + $merged = ['openapi' => '3.1.0', 'info' => ['title' => 'API', 'version' => '0.0.0'], 'paths' => [], 'components' => ['schemas' => []]]; + + foreach (glob($directory . DIRECTORY_SEPARATOR . '*.{yaml,yml}', GLOB_BRACE) ?: [] as $file) { + $fragment = Yaml::parseFile($file); + if (!\is_array($fragment)) { + continue; + } + + if (\is_array($fragment['paths'] ?? null)) { + $merged['paths'] = array_merge($merged['paths'], $fragment['paths']); + } + + if (\is_array($fragment['components']['schemas'] ?? null)) { + $merged['components']['schemas'] = array_merge($merged['components']['schemas'], $fragment['components']['schemas']); + } + } + + return Yaml::dump($merged, 8, 2); + } + + private function ensureDir(string $dir): void + { + if ($dir !== '' && !is_dir($dir) && !@mkdir($dir, 0o775, true) && !is_dir($dir)) { + throw new SdkException(\sprintf("Cannot create directory '%s'.", $dir)); + } + } +} diff --git a/src/Altair/Scaffold/Sdk/Contracts/EmitterInterface.php b/src/Altair/Scaffold/Sdk/Contracts/EmitterInterface.php new file mode 100644 index 00000000..b754cd34 --- /dev/null +++ b/src/Altair/Scaffold/Sdk/Contracts/EmitterInterface.php @@ -0,0 +1,37 @@ + $files Relative path → file contents. + */ + public function __construct( + public array $files, + ) {} + + /** + * Convenience accessor for the single-file case. + */ + public function single(): string + { + return implode("\n", $this->files); + } + + public function isMultiFile(): bool + { + return \count($this->files) > 1; + } +} diff --git a/src/Altair/Scaffold/Sdk/EmitterRegistry.php b/src/Altair/Scaffold/Sdk/EmitterRegistry.php new file mode 100644 index 00000000..69a03c4e --- /dev/null +++ b/src/Altair/Scaffold/Sdk/EmitterRegistry.php @@ -0,0 +1,69 @@ + $emitters + */ + public function __construct( + private array $emitters, + ) {} + + public static function default(): self + { + $emitters = []; + foreach ([new TypeScriptEmitter(), new PythonEmitter()] as $emitter) { + $emitters[$emitter->language()] = $emitter; + } + + return new self($emitters); + } + + public function get(string $language): EmitterInterface + { + $key = strtolower($language); + if (!isset($this->emitters[$key])) { + throw new SdkException(\sprintf( + "Unknown SDK language '%s'. Available: %s.", + $language, + implode(', ', $this->available()), + )); + } + + return $this->emitters[$key]; + } + + public function has(string $language): bool + { + return isset($this->emitters[strtolower($language)]); + } + + /** + * @return list + */ + public function available(): array + { + return array_keys($this->emitters); + } +} diff --git a/src/Altair/Scaffold/Sdk/Exception/SdkException.php b/src/Altair/Scaffold/Sdk/Exception/SdkException.php new file mode 100644 index 00000000..81bd77fc --- /dev/null +++ b/src/Altair/Scaffold/Sdk/Exception/SdkException.php @@ -0,0 +1,16 @@ + $operations + * @param array $namedSchemas Component name → schema. + */ + public function __construct( + public string $title, + public string $version, + public array $operations, + public array $namedSchemas = [], + ) {} +} diff --git a/src/Altair/Scaffold/Sdk/Model/OpenApiParser.php b/src/Altair/Scaffold/Sdk/Model/OpenApiParser.php new file mode 100644 index 00000000..8710732a --- /dev/null +++ b/src/Altair/Scaffold/Sdk/Model/OpenApiParser.php @@ -0,0 +1,373 @@ +getMessage(), 0, $parseException); + } + + if (!\is_array($decoded)) { + throw new SdkException('OpenAPI document must be a YAML map at the top level.'); + } + + return $this->parse($decoded); + } + + /** + * @param array $doc + */ + public function parse(array $doc): OpenApiDocument + { + /** @var array $info */ + $info = \is_array($doc['info'] ?? null) ? $doc['info'] : []; + /** @var array $paths */ + $paths = \is_array($doc['paths'] ?? null) ? $doc['paths'] : []; + + $namedSchemas = $this->parseComponents($doc); + $operations = []; + + foreach ($paths as $path => $methods) { + if (!\is_array($methods)) { + continue; + } + + foreach ($methods as $method => $operation) { + if (!\is_string($method)) { + continue; + } + + if (!\is_array($operation)) { + continue; + } + + $operations[] = $this->parseOperation((string) $path, strtolower($method), $operation); + } + } + + return new OpenApiDocument( + title: (string) ($info['title'] ?? 'API'), + version: (string) ($info['version'] ?? '0.0.0'), + operations: $operations, + namedSchemas: $namedSchemas, + ); + } + + /** + * @param array $doc + * + * @return array + */ + private function parseComponents(array $doc): array + { + $components = $doc['components'] ?? null; + $schemas = \is_array($components) && \is_array($components['schemas'] ?? null) ? $components['schemas'] : []; + + $out = []; + foreach ($schemas as $name => $schema) { + if (\is_string($name) && \is_array($schema)) { + $out[$name] = $this->parseSchema($schema); + } + } + + return $out; + } + + /** + * @param array $operation + */ + private function parseOperation(string $path, string $method, array $operation): OperationModel + { + $pathParams = $this->extractPathParameters($path); + + $requestBody = null; + $schema = $this->requestBodySchema($operation); + if ($schema !== null) { + $requestBody = $this->parseSchema($schema); + } + + $responses = []; + /** @var array $rawResponses */ + $rawResponses = \is_array($operation['responses'] ?? null) ? $operation['responses'] : []; + foreach ($rawResponses as $status => $response) { + if (!\is_array($response)) { + continue; + } + + $responses[] = $this->parseResponse((string) $status, $response); + } + + $operationId = isset($operation['operationId']) && \is_string($operation['operationId']) && $operation['operationId'] !== '' + ? $operation['operationId'] + : $this->synthesizeOperationId($method, $path); + + return new OperationModel( + operationId: $operationId, + method: strtoupper($method), + path: $path, + pathParameters: $pathParams, + requestBody: $requestBody, + responses: $responses, + summary: isset($operation['summary']) && \is_string($operation['summary']) ? $operation['summary'] : '', + ); + } + + /** + * @param array $operation + * + * @return array|null + */ + private function requestBodySchema(array $operation): ?array + { + $requestBody = $operation['requestBody'] ?? null; + if (!\is_array($requestBody)) { + return null; + } + + $content = $requestBody['content'] ?? null; + if (!\is_array($content)) { + return null; + } + + $json = $content['application/json'] ?? null; + if (!\is_array($json) || !\is_array($json['schema'] ?? null)) { + return null; + } + + return $json['schema']; + } + + /** + * @param array $response + */ + private function parseResponse(string $status, array $response): ResponseModel + { + $schema = null; + $content = $response['content'] ?? null; + if (\is_array($content) && \is_array($content['application/json'] ?? null) && \is_array($content['application/json']['schema'] ?? null)) { + $schema = $this->parseSchema($content['application/json']['schema']); + } + + return new ResponseModel( + status: $status, + schema: $schema, + description: isset($response['description']) && \is_string($response['description']) ? $response['description'] : '', + ); + } + + /** + * @param array $schema + */ + private function parseSchema(array $schema): SchemaType + { + $nullable = ($schema['nullable'] ?? false) === true; + + if (isset($schema['$ref']) && \is_string($schema['$ref'])) { + return SchemaType::ref($this->refName($schema['$ref']), $nullable); + } + + if (isset($schema['enum']) && \is_array($schema['enum'])) { + $values = array_values(array_map(static fn(mixed $v): string => (string) $v, $schema['enum'])); + + return SchemaType::enum($values, $nullable); + } + + $type = $schema['type'] ?? null; + // OpenAPI 3.1 allows `type: [string, "null"]`. + if (\is_array($type)) { + $nullable = $nullable || \in_array('null', $type, true); + $type = $this->firstNonNull($type); + } + + return match ($type) { + 'array' => SchemaType::arrayOf( + \is_array($schema['items'] ?? null) ? $this->parseSchema($schema['items']) : SchemaType::mixed(), + $nullable, + ), + 'object' => SchemaType::object($this->parseProperties($schema), $nullable), + 'integer' => SchemaType::scalar('integer', $this->formatOf($schema), $nullable), + 'number' => SchemaType::scalar('number', $this->formatOf($schema), $nullable), + 'boolean' => SchemaType::scalar('boolean', null, $nullable), + 'string' => SchemaType::scalar('string', $this->formatOf($schema), $nullable), + default => $this->fallbackSchema($schema, $nullable), + }; + } + + /** + * A schema with `properties` but no explicit `type` is still an object. + * + * @param array $schema + */ + private function fallbackSchema(array $schema, bool $nullable): SchemaType + { + if (\is_array($schema['properties'] ?? null)) { + return SchemaType::object($this->parseProperties($schema), $nullable); + } + + return SchemaType::mixed(); + } + + /** + * @param array $schema + * + * @return array + */ + private function parseProperties(array $schema): array + { + $properties = \is_array($schema['properties'] ?? null) ? $schema['properties'] : []; + $required = \is_array($schema['required'] ?? null) + ? array_map(static fn(mixed $v): string => (string) $v, $schema['required']) + : []; + + $out = []; + foreach ($properties as $name => $propSchema) { + if (!\is_string($name)) { + continue; + } + + if (!\is_array($propSchema)) { + continue; + } + + $out[$name] = [ + 'schema' => $this->parseSchema($propSchema), + 'required' => \in_array($name, $required, true), + ]; + } + + return $out; + } + + /** + * @return list + */ + private function extractPathParameters(string $path): array + { + preg_match_all('/\{([A-Za-z_]\w*)\}/', $path, $matches); + + return array_values($matches[1]); + } + + /** + * Synthesises a camelCase `operationId` when the document omits one: + * `POST /users` → `createUser` (singularised), `GET /users/{id}` → + * `getUsersById`, `DELETE /orders/{orderId}` → `deleteOrdersByOrderId`. + */ + private function synthesizeOperationId(string $method, string $path): string + { + $verb = match (strtoupper($method)) { + 'POST' => 'create', + 'PUT', 'PATCH' => 'update', + 'DELETE' => 'delete', + 'GET' => 'get', + default => strtolower($method), + }; + + $segments = array_values(array_filter(explode('/', $path), static fn(string $s): bool => $s !== '')); + $last = end($segments) ?: 'resource'; + + if (str_starts_with($last, '{')) { + $parameter = trim($last, '{}'); + $base = $this->lastResourceSegment($segments); + + return $verb . $this->pascalCase($base) . 'By' . $this->pascalCase($parameter); + } + + // For POST-to-collection the singular reads better: createUser, not createUsers. + $base = strtoupper($method) === 'POST' ? $this->singularize($last) : $last; + + return $verb . $this->pascalCase($base); + } + + /** + * @param list $segments + */ + private function lastResourceSegment(array $segments): string + { + for ($i = \count($segments) - 1; $i >= 0; --$i) { + if (!str_starts_with($segments[$i], '{')) { + return $segments[$i]; + } + } + + return 'resource'; + } + + private function pascalCase(string $value): string + { + $words = array_filter(preg_split('/[^a-zA-Z0-9]+/', $value) ?: []); + + return implode('', array_map(ucfirst(...), $words)); + } + + private function singularize(string $value): string + { + if (str_ends_with($value, 'ies') && \strlen($value) > 3) { + return substr($value, 0, -3) . 'y'; + } + + if (str_ends_with($value, 'ses') && \strlen($value) > 3) { + return substr($value, 0, -2); + } + + return str_ends_with($value, 's') && !str_ends_with($value, 'ss') + ? substr($value, 0, -1) + : $value; + } + + /** + * @param array $schema + */ + private function formatOf(array $schema): ?string + { + return isset($schema['format']) && \is_string($schema['format']) ? $schema['format'] : null; + } + + private function refName(string $ref): string + { + $parts = explode('/', $ref); + + return end($parts) ?: $ref; + } + + /** + * @param list $types + */ + private function firstNonNull(array $types): ?string + { + foreach ($types as $type) { + if ($type !== 'null' && \is_string($type)) { + return $type; + } + } + + return null; + } +} diff --git a/src/Altair/Scaffold/Sdk/Model/OperationModel.php b/src/Altair/Scaffold/Sdk/Model/OperationModel.php new file mode 100644 index 00000000..692fef64 --- /dev/null +++ b/src/Altair/Scaffold/Sdk/Model/OperationModel.php @@ -0,0 +1,50 @@ + $pathParameters Names of `{param}` path segments, in order. + * @param list $responses + */ + public function __construct( + public string $operationId, + public string $method, + public string $path, + public array $pathParameters, + public ?SchemaType $requestBody, + public array $responses, + public string $summary = '', + ) {} + + public function hasRequestBody(): bool + { + return $this->requestBody instanceof SchemaType; + } + + /** + * @return list + */ + public function successResponses(): array + { + return array_values(array_filter($this->responses, static fn(ResponseModel $r): bool => $r->isSuccess())); + } +} diff --git a/src/Altair/Scaffold/Sdk/Model/ResponseModel.php b/src/Altair/Scaffold/Sdk/Model/ResponseModel.php new file mode 100644 index 00000000..7d66db2a --- /dev/null +++ b/src/Altair/Scaffold/Sdk/Model/ResponseModel.php @@ -0,0 +1,41 @@ +status)) { + return false; + } + + $code = (int) $this->status; + + return $code >= 200 && $code < 300; + } + + public function statusIsNumeric(): bool + { + return ctype_digit($this->status); + } +} diff --git a/src/Altair/Scaffold/Sdk/Model/SchemaType.php b/src/Altair/Scaffold/Sdk/Model/SchemaType.php new file mode 100644 index 00000000..ca42fe9c --- /dev/null +++ b/src/Altair/Scaffold/Sdk/Model/SchemaType.php @@ -0,0 +1,92 @@ + $properties Object properties (OBJECT kind). + * @param list $enumValues Allowed values (ENUM kind). + */ + public function __construct( + public string $kind, + public ?string $scalarType = null, + public ?SchemaType $items = null, + public array $properties = [], + public ?string $ref = null, + public array $enumValues = [], + public ?string $format = null, + public bool $nullable = false, + ) {} + + public static function scalar(string $scalarType, ?string $format = null, bool $nullable = false): self + { + return new self(kind: self::SCALAR, scalarType: $scalarType, format: $format, nullable: $nullable); + } + + public static function mixed(): self + { + return new self(kind: self::MIXED); + } + + public static function arrayOf(self $items, bool $nullable = false): self + { + return new self(kind: self::ARRAY, items: $items, nullable: $nullable); + } + + public static function ref(string $ref, bool $nullable = false): self + { + return new self(kind: self::REF, ref: $ref, nullable: $nullable); + } + + /** + * @param array $properties + */ + public static function object(array $properties, bool $nullable = false): self + { + return new self(kind: self::OBJECT, properties: $properties, nullable: $nullable); + } + + /** + * @param list $values + */ + public static function enum(array $values, bool $nullable = false): self + { + return new self(kind: self::ENUM, scalarType: 'string', enumValues: $values, nullable: $nullable); + } + + public function isObject(): bool + { + return $this->kind === self::OBJECT; + } +} diff --git a/src/Altair/Scaffold/Sdk/Python/PythonEmitter.php b/src/Altair/Scaffold/Sdk/Python/PythonEmitter.php new file mode 100644 index 00000000..a5b04154 --- /dev/null +++ b/src/Altair/Scaffold/Sdk/Python/PythonEmitter.php @@ -0,0 +1,298 @@ +renderModels($document); + $sync = $this->renderClient($document, async: false); + $async = $this->renderClient($document, async: true); + + if ($multiFile) { + return new EmittedSdk([ + 'models.py' => $this->header() . $this->modelImports() . $models, + 'client.py' => $this->header() . $this->clientImports() . "from .models import * # noqa: F403\n\n\n" . $sync . "\n\n" . $async, + ]); + } + + $body = $this->header() + . $this->fullImports() + . $models + . "\n" + . $sync + . "\n\n" + . $async; + + return new EmittedSdk([$this->defaultFileName() => rtrim($body) . "\n"]); + } + + private function header(): string + { + return "# generated by univeros/scaffold — do not edit by hand.\n# ruff: noqa\n\n"; + } + + private function fullImports(): string + { + return "from __future__ import annotations\n\n" + . "from enum import Enum\n" + . "from typing import Any\n\n" + . "import httpx\n" + . "from pydantic import BaseModel, Field\n\n\n"; + } + + private function modelImports(): string + { + return "from __future__ import annotations\n\n" + . "from enum import Enum\n" + . "from typing import Any\n\n" + . "from pydantic import BaseModel, Field\n\n\n"; + } + + private function clientImports(): string + { + return "from __future__ import annotations\n\n" + . "from typing import Any\n\n" + . "import httpx\n\n"; + } + + private function renderModels(OpenApiDocument $document): string + { + $names = array_keys($document->namedSchemas); + sort($names); + + $out = ''; + foreach ($names as $name) { + $schema = $document->namedSchemas[$name]; + if ($schema->kind === SchemaType::ENUM) { + $out .= $this->renderEnum($name, $schema) . "\n\n"; + } elseif ($schema->isObject()) { + $out .= $this->renderModel($name, $schema) . "\n\n"; + } + } + + return $out; + } + + private function renderEnum(string $name, SchemaType $schema): string + { + $out = \sprintf("class %s(str, Enum):\n", $name); + if ($schema->enumValues === []) { + return $out . " pass\n"; + } + + foreach ($schema->enumValues as $value) { + $out .= \sprintf(" %s = %s\n", $this->constName($value), $this->pyString($value)); + } + + return $out; + } + + private function renderModel(string $name, SchemaType $schema): string + { + $out = \sprintf("class %s(BaseModel):\n", $name); + if ($schema->properties === []) { + return $out . " pass\n"; + } + + foreach ($schema->properties as $wireName => $property) { + $fieldName = $this->snake($wireName); + $type = $this->typeHint($property['schema']); + $optional = !$property['required']; + + if ($optional) { + $type .= ' | None'; + } + + $default = $this->fieldDefault($wireName, $fieldName, $optional); + $out .= \sprintf(" %s: %s%s\n", $fieldName, $type, $default); + } + + return $out; + } + + private function fieldDefault(string $wireName, string $fieldName, bool $optional): string + { + $needsAlias = $wireName !== $fieldName; + + if ($needsAlias && $optional) { + return \sprintf(' = Field(default=None, alias=%s)', $this->pyString($wireName)); + } + + if ($needsAlias) { + return \sprintf(' = Field(alias=%s)', $this->pyString($wireName)); + } + + if ($optional) { + return ' = None'; + } + + return ''; + } + + private function renderClient(OpenApiDocument $document, bool $async): string + { + $className = $async ? 'AsyncClient' : 'Client'; + $httpxClient = $async ? 'httpx.AsyncClient' : 'httpx.Client'; + + $out = \sprintf("class %s:\n", $className); + $out .= \sprintf(" def __init__(self, base_url: str, client: %s | None = None) -> None:\n", $httpxClient); + $out .= \sprintf(" self._client = client or %s(base_url=base_url)\n\n", $httpxClient); + + foreach ($document->operations as $operation) { + $out .= $this->renderMethod($operation, $async); + } + + return rtrim($out, "\n") . "\n"; + } + + private function renderMethod(OperationModel $operation, bool $async): string + { + $name = $this->snake($operation->operationId); + $params = ['self']; + foreach ($operation->pathParameters as $param) { + $params[] = $this->snake($param) . ': str'; + } + + if ($operation->hasRequestBody()) { + $params[] = 'body: ' . $this->typeHint($operation->requestBody); + } + + $returnType = $this->returnType($operation); + $def = $async ? 'async def' : 'def'; + $await = $async ? 'await ' : ''; + + $pathExpr = $this->pathExpression($operation); + $bodyArg = $operation->hasRequestBody() + ? ', json=body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, "model_dump") else body' + : ''; + + $out = \sprintf(" %s %s(%s) -> %s:\n", $def, $name, implode(', ', $params), $returnType); + if ($operation->summary !== '') { + $out .= \sprintf(" \"\"\"%s\"\"\"\n", $operation->summary); + } + + $out .= \sprintf(" response = %sself._client.request(%s, %s%s)\n", $await, $this->pyString($operation->method), $pathExpr, $bodyArg); + $out .= " response.raise_for_status()\n"; + + return $out . " return response.json()\n\n"; + } + + private function returnType(OperationModel $operation): string + { + $successSchemas = []; + foreach ($operation->successResponses() as $response) { + if ($response->schema !== null) { + $successSchemas[$this->typeHint($response->schema)] = true; + } + } + + $types = array_keys($successSchemas); + + return match (\count($types)) { + 0 => 'Any', + 1 => $types[0], + default => implode(' | ', $types), + }; + } + + private function pathExpression(OperationModel $operation): string + { + if ($operation->pathParameters === []) { + return $this->pyString($operation->path); + } + + $template = $operation->path; + foreach ($operation->pathParameters as $param) { + $template = str_replace('{' . $param . '}', '{' . $this->snake($param) . '}', $template); + } + + return 'f' . $this->pyString($template); + } + + private function typeHint(SchemaType $schema): string + { + $base = match ($schema->kind) { + SchemaType::REF => (string) $schema->ref, + SchemaType::ARRAY => 'list[' . $this->typeHint($schema->items ?? SchemaType::mixed()) . ']', + SchemaType::ENUM => $schema->enumValues === [] ? 'str' : 'str', + SchemaType::OBJECT => 'dict[str, Any]', + SchemaType::SCALAR => $this->scalarHint($schema), + default => 'Any', + }; + + return $schema->nullable ? $base . ' | None' : $base; + } + + private function scalarHint(SchemaType $schema): string + { + return match ($schema->scalarType) { + 'integer' => 'int', + 'number' => 'float', + 'boolean' => 'bool', + default => $schema->format === 'date-time' ? 'str' : 'str', + }; + } + + private function snake(string $value): string + { + $value = (string) preg_replace('/[^A-Za-z0-9]+/', '_', $value); + $value = (string) preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $value); + + return strtolower(trim($value, '_')); + } + + private function constName(string $value): string + { + $name = strtoupper($this->snake($value)); + + return preg_match('/^[A-Z]/', $name) === 1 ? $name : 'V_' . $name; + } + + private function pyString(string $value): string + { + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"'; + } +} diff --git a/src/Altair/Scaffold/Sdk/TypeScript/TypeScriptEmitter.php b/src/Altair/Scaffold/Sdk/TypeScript/TypeScriptEmitter.php new file mode 100644 index 00000000..12c30936 --- /dev/null +++ b/src/Altair/Scaffold/Sdk/TypeScript/TypeScriptEmitter.php @@ -0,0 +1,295 @@ +renderNamedTypes($document); + $operations = $this->renderOperations($document); + + if ($multiFile) { + return new EmittedSdk([ + 'types.ts' => $this->header() . $types . "\n", + 'client.ts' => $this->header() . "import type * as Types from './types';\n\n" . $this->runtime() . $operations, + ]); + } + + $body = $this->header() + . $this->runtime() + . ($types === '' ? '' : $types . "\n") + . $operations; + + return new EmittedSdk([$this->defaultFileName() => rtrim($body) . "\n"]); + } + + private function header(): string + { + return "// generated by univeros/scaffold — do not edit by hand.\n" + . "/* eslint-disable */\n\n"; + } + + private function runtime(): string + { + return <<<'TS' + export interface ApiOptions { + baseUrl?: string; + headers?: Record; + signal?: AbortSignal; + fetch?: typeof fetch; + } + + export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + ) { + super(`API request failed with status ${status}`); + this.name = 'ApiError'; + } + } + + async function request( + method: string, + path: string, + options: ApiOptions & { body?: unknown } = {}, + ): Promise<{ status: number; data: unknown }> { + const { baseUrl = '', headers = {}, signal, fetch: fetchImpl = fetch, body } = options; + const response = await fetchImpl(`${baseUrl}${path}`, { + method, + headers: { 'content-type': 'application/json', ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }); + const text = await response.text(); + const data = text === '' ? null : JSON.parse(text); + return { status: response.status, data }; + } + + + TS; + } + + private function renderNamedTypes(OpenApiDocument $document): string + { + $names = array_keys($document->namedSchemas); + sort($names); + + $out = ''; + foreach ($names as $name) { + $out .= $this->renderNamedType($name, $document->namedSchemas[$name]) . "\n"; + } + + return $out; + } + + private function renderNamedType(string $name, SchemaType $schema): string + { + if ($schema->kind === SchemaType::ENUM) { + return \sprintf("export type %s = %s;\n", $name, $this->enumUnion($schema)); + } + + if ($schema->isObject()) { + return \sprintf("export interface %s {\n%s}\n", $name, $this->objectBody($schema)); + } + + return \sprintf("export type %s = %s;\n", $name, $this->typeExpr($schema)); + } + + private function renderOperations(OpenApiDocument $document): string + { + $out = ''; + foreach ($document->operations as $operation) { + $out .= $this->renderOperation($operation); + } + + return $out; + } + + private function renderOperation(OperationModel $operation): string + { + $fn = $operation->operationId; + $responseType = ucfirst($fn) . 'Response'; + + $out = $this->renderResponseUnion($responseType, $operation) . "\n"; + + $params = []; + foreach ($operation->pathParameters as $param) { + $params[] = $this->camel($param) . ': string'; + } + + if ($operation->hasRequestBody()) { + $params[] = 'body: ' . $this->typeExpr($operation->requestBody); + } + + $params[] = 'options: ApiOptions = {}'; + + $pathExpr = $this->pathExpression($operation); + $requestArgs = $operation->hasRequestBody() ? 'body, ...options' : '...options'; + + $summary = $operation->summary !== '' ? '/** ' . $operation->summary . " */\n" : ''; + + return $out . \sprintf( + "%sexport async function %s(%s): Promise<%s> {\n return request('%s', %s, { %s }) as Promise<%s>;\n}\n\n", + $summary, + $fn, + implode(', ', $params), + $responseType, + $operation->method, + $pathExpr, + $requestArgs, + $responseType, + ); + } + + private function renderResponseUnion(string $typeName, OperationModel $operation): string + { + if ($operation->responses === []) { + return \sprintf("export type %s = { status: number; data: unknown };\n", $typeName); + } + + $members = []; + foreach ($operation->responses as $response) { + $statusToken = $response->statusIsNumeric() ? $response->status : 'number'; + $dataType = $response->schema === null ? 'null' : $this->typeExpr($response->schema); + $members[] = \sprintf(' | { status: %s; data: %s }', $statusToken, $dataType); + } + + return \sprintf("export type %s =\n%s;\n", $typeName, implode("\n", $members)); + } + + private function pathExpression(OperationModel $operation): string + { + if ($operation->pathParameters === []) { + return "'" . $operation->path . "'"; + } + + $template = $operation->path; + foreach ($operation->pathParameters as $param) { + $template = str_replace('{' . $param . '}', '${' . $this->camel($param) . '}', $template); + } + + return '`' . $template . '`'; + } + + private function objectBody(SchemaType $schema): string + { + $out = ''; + foreach ($schema->properties as $name => $property) { + $optional = $property['required'] ? '' : '?'; + $out .= \sprintf(" %s%s: %s;\n", $this->propertyName($name), $optional, $this->typeExpr($property['schema'])); + } + + return $out; + } + + private function typeExpr(SchemaType $schema): string + { + $base = match ($schema->kind) { + SchemaType::REF => (string) $schema->ref, + SchemaType::ARRAY => $this->arrayExpr($schema), + SchemaType::ENUM => $this->enumUnion($schema), + SchemaType::OBJECT => $this->inlineObject($schema), + SchemaType::SCALAR => $this->scalarExpr($schema), + default => 'unknown', + }; + + return $schema->nullable ? $base . ' | null' : $base; + } + + private function arrayExpr(SchemaType $schema): string + { + $items = $schema->items ?? SchemaType::mixed(); + $inner = $this->typeExpr($items); + + // Wrap unions so `(A | null)[]` reads correctly. + return str_contains($inner, ' ') ? '(' . $inner . ')[]' : $inner . '[]'; + } + + private function inlineObject(SchemaType $schema): string + { + if ($schema->properties === []) { + return 'Record'; + } + + $parts = []; + foreach ($schema->properties as $name => $property) { + $optional = $property['required'] ? '' : '?'; + $parts[] = \sprintf('%s%s: %s', $this->propertyName($name), $optional, $this->typeExpr($property['schema'])); + } + + return '{ ' . implode('; ', $parts) . ' }'; + } + + private function enumUnion(SchemaType $schema): string + { + if ($schema->enumValues === []) { + return 'string'; + } + + return implode(' | ', array_map(static fn(string $v): string => "'" . $v . "'", $schema->enumValues)); + } + + private function scalarExpr(SchemaType $schema): string + { + return match ($schema->scalarType) { + 'integer', 'number' => 'number', + 'boolean' => 'boolean', + default => 'string', + }; + } + + private function propertyName(string $name): string + { + return preg_match('/^[A-Za-z_$][A-Za-z0-9_$]*$/', $name) === 1 ? $name : "'" . $name . "'"; + } + + private function camel(string $value): string + { + $value = (string) preg_replace('/[^A-Za-z0-9]+/', ' ', $value); + + return lcfirst(str_replace(' ', '', ucwords($value))); + } +} diff --git a/tests/Scaffold/Sdk/CompileIntegrationTest.php b/tests/Scaffold/Sdk/CompileIntegrationTest.php new file mode 100644 index 00000000..5e5ba7da --- /dev/null +++ b/tests/Scaffold/Sdk/CompileIntegrationTest.php @@ -0,0 +1,91 @@ +tmpDir = sys_get_temp_dir() . '/altair-sdk-compile-' . bin2hex(random_bytes(4)); + @mkdir($this->tmpDir, 0o775, true); + } + + #[Override] + protected function tearDown(): void + { + foreach (glob($this->tmpDir . '/*') ?: [] as $f) { + @unlink($f); + } + + @rmdir($this->tmpDir); + } + + public function testEmittedTypeScriptCompilesUnderTsc(): void + { + $tsc = $this->locateBinary('tsc'); + if ($tsc === null) { + $this->markTestSkipped('tsc is not installed.'); + } + + $file = $this->tmpDir . '/sdk.ts'; + file_put_contents($file, $this->emitTypeScript()); + + exec(\sprintf('%s --strict --noEmit --skipLibCheck %s 2>&1', escapeshellarg($tsc), escapeshellarg($file)), $output, $exitCode); + $this->assertSame(0, $exitCode, "tsc errors:\n" . implode("\n", $output)); + } + + public function testEmittedPythonPassesMypy(): void + { + $mypy = $this->locateBinary('mypy'); + if ($mypy === null) { + $this->markTestSkipped('mypy is not installed.'); + } + + $file = $this->tmpDir . '/client.py'; + file_put_contents($file, $this->emitPython()); + + exec(\sprintf('%s --strict --ignore-missing-imports %s 2>&1', escapeshellarg($mypy), escapeshellarg($file)), $output, $exitCode); + $this->assertSame(0, $exitCode, "mypy errors:\n" . implode("\n", $output)); + } + + private function emitTypeScript(): string + { + $doc = (new OpenApiParser())->parseYaml((string) file_get_contents(__DIR__ . '/Fixtures/users-api.yaml')); + + return (new TypeScriptEmitter())->emit($doc)->single(); + } + + private function emitPython(): string + { + $doc = (new OpenApiParser())->parseYaml((string) file_get_contents(__DIR__ . '/Fixtures/users-api.yaml')); + + return (new PythonEmitter())->emit($doc)->single(); + } + + private function locateBinary(string $name): ?string + { + $which = \PHP_OS_FAMILY === 'Windows' ? 'where' : 'command -v'; + $path = trim((string) shell_exec(\sprintf('%s %s 2>/dev/null', $which, escapeshellarg($name)))); + + return $path === '' ? null : $name; + } +} diff --git a/tests/Scaffold/Sdk/EmitSdkCommandTest.php b/tests/Scaffold/Sdk/EmitSdkCommandTest.php new file mode 100644 index 00000000..e03cc674 --- /dev/null +++ b/tests/Scaffold/Sdk/EmitSdkCommandTest.php @@ -0,0 +1,120 @@ +tmpDir = sys_get_temp_dir() . '/altair-sdk-cli-' . bin2hex(random_bytes(4)); + @mkdir($this->tmpDir, 0o775, true); + $this->openapiPath = $this->tmpDir . '/openapi.yaml'; + copy(__DIR__ . '/Fixtures/users-api.yaml', $this->openapiPath); + } + + #[Override] + protected function tearDown(): void + { + foreach (glob($this->tmpDir . '/*') ?: [] as $f) { + is_dir($f) ? $this->rrmdir($f) : @unlink($f); + } + + @rmdir($this->tmpDir); + } + + public function testListShowsAvailableLanguages(): void + { + ob_start(); + $exit = (new EmitSdkCommand())(language: null, list: true); + $output = (string) ob_get_clean(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('typescript', $output); + $this->assertStringContainsString('python', $output); + } + + public function testUnknownLanguageExitsTwo(): void + { + ob_start(); + $exit = (new EmitSdkCommand())(language: 'cobol', openapi: $this->openapiPath); + ob_get_clean(); + $this->assertSame(2, $exit); + } + + public function testEmitsTypeScriptToStdout(): void + { + ob_start(); + $exit = (new EmitSdkCommand())(language: 'typescript', openapi: $this->openapiPath); + $output = (string) ob_get_clean(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('export async function createUser', $output); + } + + public function testEmitsToFileAndCheckPasses(): void + { + $out = $this->tmpDir . '/sdk.ts'; + + ob_start(); + $writeExit = (new EmitSdkCommand())(language: 'typescript', openapi: $this->openapiPath, out: $out); + ob_get_clean(); + + $this->assertSame(0, $writeExit); + $this->assertFileExists($out); + + // --check against the just-written file should report no drift. + ob_start(); + $checkExit = (new EmitSdkCommand())(language: 'typescript', openapi: $this->openapiPath, out: $out, check: true); + $checkOutput = (string) ob_get_clean(); + + $this->assertSame(0, $checkExit); + $this->assertStringContainsString('up to date', $checkOutput); + } + + public function testCheckDetectsDrift(): void + { + $out = $this->tmpDir . '/sdk.ts'; + file_put_contents($out, "// stale hand-edited content\n"); + + ob_start(); + $exit = (new EmitSdkCommand())(language: 'typescript', openapi: $this->openapiPath, out: $out, check: true); + $output = (string) ob_get_clean(); + + $this->assertSame(1, $exit); + $this->assertStringContainsString('drift detected', $output); + } + + public function testMultiFilePythonWritesTwoFiles(): void + { + $out = $this->tmpDir . '/py'; + + ob_start(); + (new EmitSdkCommand())(language: 'python', openapi: $this->openapiPath, out: $out, multiFile: true); + ob_get_clean(); + + $this->assertFileExists($out . '/models.py'); + $this->assertFileExists($out . '/client.py'); + } + + private function rrmdir(string $dir): void + { + foreach (glob($dir . '/*') ?: [] as $f) { + is_dir($f) ? $this->rrmdir($f) : @unlink($f); + } + + @rmdir($dir); + } +} diff --git a/tests/Scaffold/Sdk/EmitterRegistryTest.php b/tests/Scaffold/Sdk/EmitterRegistryTest.php new file mode 100644 index 00000000..b14b64c5 --- /dev/null +++ b/tests/Scaffold/Sdk/EmitterRegistryTest.php @@ -0,0 +1,42 @@ +assertSame(['typescript', 'python'], $registry->available()); + $this->assertInstanceOf(TypeScriptEmitter::class, $registry->get('typescript')); + $this->assertInstanceOf(PythonEmitter::class, $registry->get('python')); + } + + public function testGetIsCaseInsensitive(): void + { + $this->assertInstanceOf(TypeScriptEmitter::class, EmitterRegistry::default()->get('TypeScript')); + } + + public function testHasReportsAvailability(): void + { + $registry = EmitterRegistry::default(); + $this->assertTrue($registry->has('python')); + $this->assertFalse($registry->has('go')); + } + + public function testUnknownLanguageThrows(): void + { + $this->expectException(SdkException::class); + EmitterRegistry::default()->get('go'); + } +} diff --git a/tests/Scaffold/Sdk/Fixtures/users-api.yaml b/tests/Scaffold/Sdk/Fixtures/users-api.yaml new file mode 100644 index 00000000..764fc4f2 --- /dev/null +++ b/tests/Scaffold/Sdk/Fixtures/users-api.yaml @@ -0,0 +1,65 @@ +openapi: 3.1.0 +info: + title: Users API + version: 1.0.0 +paths: + /users: + post: + operationId: createUser + summary: Create a new user + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: { type: string } + password: { type: string } + role: { $ref: '#/components/schemas/UserRole' } + responses: + '201': + description: Created + content: + application/json: + schema: + type: object + properties: + user: { $ref: '#/components/schemas/User' } + '422': + description: Validation failed + content: + application/json: + schema: + type: object + properties: + errors: + type: array + items: { type: string } + /users/{id}: + get: + operationId: getUser + summary: Fetch one user + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: Not found +components: + schemas: + UserRole: + type: string + enum: [admin, member, viewer] + User: + type: object + required: [id, email, role] + properties: + id: { type: string } + email: { type: string } + role: { $ref: '#/components/schemas/UserRole' } + created_at: { type: string, format: date-time } diff --git a/tests/Scaffold/Sdk/OpenApiParserTest.php b/tests/Scaffold/Sdk/OpenApiParserTest.php new file mode 100644 index 00000000..0b2c1b46 --- /dev/null +++ b/tests/Scaffold/Sdk/OpenApiParserTest.php @@ -0,0 +1,96 @@ +parseYaml($this->fixture()); + + $this->assertSame('Users API', $doc->title); + $this->assertSame('1.0.0', $doc->version); + $this->assertCount(2, $doc->operations); + } + + public function testParsesNamedSchemasIncludingEnum(): void + { + $doc = (new OpenApiParser())->parseYaml($this->fixture()); + + $this->assertArrayHasKey('UserRole', $doc->namedSchemas); + $this->assertArrayHasKey('User', $doc->namedSchemas); + $this->assertSame(SchemaType::ENUM, $doc->namedSchemas['UserRole']->kind); + $this->assertSame(['admin', 'member', 'viewer'], $doc->namedSchemas['UserRole']->enumValues); + $this->assertTrue($doc->namedSchemas['User']->isObject()); + } + + public function testParsesRequestBodyAndPathParameters(): void + { + $doc = (new OpenApiParser())->parseYaml($this->fixture()); + + $byId = []; + foreach ($doc->operations as $op) { + $byId[$op->operationId] = $op; + } + + $this->assertTrue($byId['createUser']->hasRequestBody()); + $this->assertSame('POST', $byId['createUser']->method); + $this->assertSame([], $byId['createUser']->pathParameters); + + $this->assertFalse($byId['getUser']->hasRequestBody()); + $this->assertSame(['id'], $byId['getUser']->pathParameters); + } + + public function testParsesResponseUnion(): void + { + $doc = (new OpenApiParser())->parseYaml($this->fixture()); + $create = $doc->operations[0]; + + $statuses = array_map(static fn($r): string => $r->status, $create->responses); + $this->assertContains('201', $statuses); + $this->assertContains('422', $statuses); + $this->assertCount(1, $create->successResponses()); + } + + public function testSynthesisesOperationIdWhenAbsent(): void + { + $doc = (new OpenApiParser())->parse([ + 'info' => ['title' => 'X', 'version' => '1'], + 'paths' => [ + '/orders/{orderId}' => [ + 'delete' => ['responses' => ['204' => ['description' => 'gone']]], + ], + ], + ]); + + $this->assertSame('deleteOrdersByOrderId', $doc->operations[0]->operationId); + } + + public function testRejectsNonMapTopLevel(): void + { + $this->expectException(SdkException::class); + // A bare scalar parses to a string, not a map → rejected. + (new OpenApiParser())->parseYaml('just-a-scalar-string'); + } +} diff --git a/tests/Scaffold/Sdk/PythonEmitterTest.php b/tests/Scaffold/Sdk/PythonEmitterTest.php new file mode 100644 index 00000000..ebbd1d8c --- /dev/null +++ b/tests/Scaffold/Sdk/PythonEmitterTest.php @@ -0,0 +1,78 @@ +parseYaml((string) file_get_contents(__DIR__ . '/Fixtures/users-api.yaml')); + + return (new PythonEmitter())->emit($doc, $multiFile)->single(); + } + + public function testEmitsEnumClass(): void + { + $py = $this->emit(); + $this->assertStringContainsString('class UserRole(str, Enum):', $py); + $this->assertStringContainsString('ADMIN = "admin"', $py); + $this->assertStringContainsString('VIEWER = "viewer"', $py); + } + + public function testEmitsPydanticModel(): void + { + $py = $this->emit(); + $this->assertStringContainsString('class User(BaseModel):', $py); + $this->assertStringContainsString('id: str', $py); + $this->assertStringContainsString('role: UserRole', $py); + // Optional field gets `| None`. + $this->assertStringContainsString('created_at: str | None', $py); + } + + public function testEmitsSyncAndAsyncClients(): void + { + $py = $this->emit(); + $this->assertStringContainsString('class Client:', $py); + $this->assertStringContainsString('class AsyncClient:', $py); + $this->assertStringContainsString('def create_user(self', $py); + $this->assertStringContainsString('async def create_user(self', $py); + } + + public function testPathParamMethodUsesFstring(): void + { + $py = $this->emit(); + $this->assertStringContainsString('def get_user(self, id: str)', $py); + $this->assertStringContainsString('f"/users/{id}"', $py); + } + + public function testImportsHttpxAndPydantic(): void + { + $py = $this->emit(); + $this->assertStringContainsString('import httpx', $py); + $this->assertStringContainsString('from pydantic import BaseModel, Field', $py); + } + + public function testOutputIsDeterministic(): void + { + $this->assertSame($this->emit(), $this->emit()); + } + + public function testMultiFileSplitsModelsAndClient(): void + { + $doc = (new OpenApiParser())->parseYaml((string) file_get_contents(__DIR__ . '/Fixtures/users-api.yaml')); + $emitted = (new PythonEmitter())->emit($doc, multiFile: true); + + $this->assertArrayHasKey('models.py', $emitted->files); + $this->assertArrayHasKey('client.py', $emitted->files); + $this->assertStringContainsString('class User(BaseModel):', $emitted->files['models.py']); + $this->assertStringContainsString('class AsyncClient:', $emitted->files['client.py']); + } +} diff --git a/tests/Scaffold/Sdk/TypeScriptEmitterTest.php b/tests/Scaffold/Sdk/TypeScriptEmitterTest.php new file mode 100644 index 00000000..29c2120b --- /dev/null +++ b/tests/Scaffold/Sdk/TypeScriptEmitterTest.php @@ -0,0 +1,83 @@ +parseYaml((string) file_get_contents(__DIR__ . '/Fixtures/users-api.yaml')); + + return (new TypeScriptEmitter())->emit($doc, $multiFile)->single(); + } + + public function testEmitsEnumAsUnionType(): void + { + $ts = $this->emit(); + $this->assertStringContainsString("export type UserRole = 'admin' | 'member' | 'viewer';", $ts); + } + + public function testEmitsObjectAsInterface(): void + { + $ts = $this->emit(); + $this->assertStringContainsString('export interface User {', $ts); + $this->assertStringContainsString('id: string;', $ts); + $this->assertStringContainsString('role: UserRole;', $ts); + // Optional field uses `?`. + $this->assertStringContainsString('created_at?: string;', $ts); + } + + public function testEmitsStatusDiscriminatedUnion(): void + { + $ts = $this->emit(); + $this->assertStringContainsString('export type CreateUserResponse =', $ts); + $this->assertStringContainsString('{ status: 201; data:', $ts); + $this->assertStringContainsString('{ status: 422; data:', $ts); + } + + public function testEmitsTreeShakeableFunctionPerOperation(): void + { + $ts = $this->emit(); + $this->assertStringContainsString('export async function createUser(', $ts); + $this->assertStringContainsString('export async function getUser(', $ts); + // Path parameter becomes a function arg + template literal. + $this->assertStringContainsString('id: string', $ts); + $this->assertStringContainsString('`/users/${id}`', $ts); + } + + public function testEmitsRuntimeHelpers(): void + { + $ts = $this->emit(); + $this->assertStringContainsString('export interface ApiOptions', $ts); + $this->assertStringContainsString('export class ApiError extends Error', $ts); + $this->assertStringContainsString('async function request(', $ts); + $this->assertStringContainsString('do not edit by hand', $ts); + } + + public function testOutputIsDeterministic(): void + { + $this->assertSame($this->emit(), $this->emit()); + } + + public function testMultiFileSplitsTypesAndClient(): void + { + $doc = (new OpenApiParser())->parseYaml((string) file_get_contents(__DIR__ . '/Fixtures/users-api.yaml')); + $emitted = (new TypeScriptEmitter())->emit($doc, multiFile: true); + + $this->assertTrue($emitted->isMultiFile()); + $this->assertArrayHasKey('types.ts', $emitted->files); + $this->assertArrayHasKey('client.ts', $emitted->files); + $this->assertStringContainsString('export interface User', $emitted->files['types.ts']); + $this->assertStringContainsString('export async function createUser', $emitted->files['client.ts']); + } +}