Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .agent/packages/scaffold.md
Original file line number Diff line number Diff line change
@@ -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 <language>` 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=<path>` 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)

Expand Down
2 changes: 1 addition & 1 deletion AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
235 changes: 235 additions & 0 deletions src/Altair/Scaffold/Cli/EmitSdkCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Scaffold\Cli;

use Altair\Cli\Attribute\Argument;
use Altair\Cli\Attribute\Command;
use Altair\Cli\Attribute\Option;
use Altair\Scaffold\Sdk\EmitterRegistry;
use Altair\Scaffold\Sdk\Exception\SdkException;
use Altair\Scaffold\Sdk\Model\OpenApiDocument;
use Altair\Scaffold\Sdk\Model\OpenApiParser;

use const DIRECTORY_SEPARATOR;

use Symfony\Component\Yaml\Yaml;
use Throwable;

/**
* `bin/altair spec:emit-sdk <language>` — 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=<path>` 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<string, string> $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<string, string> $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));
}
}
}
37 changes: 37 additions & 0 deletions src/Altair/Scaffold/Sdk/Contracts/EmitterInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Scaffold\Sdk\Contracts;

use Altair\Scaffold\Sdk\EmittedSdk;
use Altair\Scaffold\Sdk\Model\OpenApiDocument;

/**
* One target-language SDK emitter.
*
* Emitters are pure: same {@see OpenApiDocument} in → byte-identical
* {@see EmittedSdk} out, so `--check` mode can diff regenerated content
* against what's on disk for CI drift detection.
*/
interface EmitterInterface
{
/**
* Language identifier used on the CLI (`typescript`, `python`).
*/
public function language(): string;

/**
* Default single-file output filename (`sdk.ts`, `client.py`).
*/
public function defaultFileName(): string;

public function emit(OpenApiDocument $document, bool $multiFile = false): EmittedSdk;
}
42 changes: 42 additions & 0 deletions src/Altair/Scaffold/Sdk/EmittedSdk.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Scaffold\Sdk;

/**
* The output of an emitter: one or more files keyed by relative path.
*
* Single-file emission (the default) produces one entry; `--multi-file`
* produces several. The CLI command writes each entry to disk (or, for
* the single-file case, to stdout).
*/
final readonly class EmittedSdk
{
/**
* @param array<string, string> $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;
}
}
Loading
Loading