From b02d25819efde199b3393270d2152257d780e29d Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Sat, 30 May 2026 22:48:16 +0200 Subject: [PATCH] feat(scaffold): x-altair-* OpenAPI extensions for round-trippable spec fields (#163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the x-altair-* extension family so framework-specific spec fields survive a round trip through OpenAPI 3.1: - x-altair-domain spec.domain (class, invocation) - x-altair-persistence spec.persistence (entity + repository + fields) - x-altair-queue spec.queue (dispatches as a list) - x-altair-idempotency reserved; key + schema only this release - x-altair-webhook reserved; key + schema only this release - x-altair-input-location reserved; needs parameters[] parser support Forward (spec:emit-openapi) - OpenApiEmitter writes x-altair-domain on every operation and x-altair-persistence / x-altair-queue when the spec carries those blocks. Reverse (openapi:import) - OperationModel grew an extensions: array field. - OpenApiParser extracts every x-altair-* key on each operation onto that field so unknown keys ride along verbatim (forward compat). - OperationMapper reads x-altair-domain / x-altair-persistence / x-altair-queue when building the spec structure, so an imported endpoint keeps the original domain FQCN, persistence block, and queue map instead of falling back to path-derived defaults. - The runner surfaces a warning entry in ImportReceipt.warnings for any x-altair-* key outside the v1 known set. Documentation - docs/openapi/extensions.md walks through the v1 key set, forward/reverse semantics, round-trip example, and what does not round-trip yet. - docs/openapi/extensions/*.schema.json — Draft 2020-12 JSON Schemas for each key. Tests - 16 new tests across emitter, parser, mapper, and runner extension coverage. Existing OpenApiEmitterTest golden snapshot regenerated to include the new x-altair-domain block. Part of #160. Closes #163. --- .agent/packages/scaffold.md | 4 + docs/openapi/extensions.md | 165 ++++++++++++++ .../extensions/x-altair-domain.schema.json | 21 ++ .../x-altair-idempotency.schema.json | 21 ++ .../x-altair-input-location.schema.json | 22 ++ .../x-altair-persistence.schema.json | 58 +++++ .../extensions/x-altair-queue.schema.json | 36 +++ .../extensions/x-altair-webhook.schema.json | 29 +++ .../Scaffold/Cli/OpenApiImportRunner.php | 59 ++++- .../Scaffold/Emitter/OpenApiEmitter.php | 111 ++++++++++ .../Scaffold/Sdk/Model/OpenApiParser.php | 23 ++ .../Scaffold/Sdk/Model/OperationModel.php | 2 + .../Scaffold/Spec/Emitter/OperationMapper.php | 74 ++++++- .../Cli/OpenApiImportExtensionsTest.php | 205 ++++++++++++++++++ .../Emitter/OpenApiEmitterExtensionsTest.php | 72 ++++++ .../Sdk/OpenApiParserExtensionsTest.php | 59 +++++ .../Snapshots/create-user.openapi.yaml | 3 + .../Emitter/OperationMapperExtensionsTest.php | 125 +++++++++++ 18 files changed, 1081 insertions(+), 8 deletions(-) create mode 100644 docs/openapi/extensions.md create mode 100644 docs/openapi/extensions/x-altair-domain.schema.json create mode 100644 docs/openapi/extensions/x-altair-idempotency.schema.json create mode 100644 docs/openapi/extensions/x-altair-input-location.schema.json create mode 100644 docs/openapi/extensions/x-altair-persistence.schema.json create mode 100644 docs/openapi/extensions/x-altair-queue.schema.json create mode 100644 docs/openapi/extensions/x-altair-webhook.schema.json create mode 100644 tests/Scaffold/Cli/OpenApiImportExtensionsTest.php create mode 100644 tests/Scaffold/Emitter/OpenApiEmitterExtensionsTest.php create mode 100644 tests/Scaffold/Sdk/OpenApiParserExtensionsTest.php create mode 100644 tests/Scaffold/Spec/Emitter/OperationMapperExtensionsTest.php diff --git a/.agent/packages/scaffold.md b/.agent/packages/scaffold.md index e2a3a23b..2a324d4c 100644 --- a/.agent/packages/scaffold.md +++ b/.agent/packages/scaffold.md @@ -88,6 +88,7 @@ ## Tests as documentation - `tests/Scaffold/Cli/ImportReceiptTest.php` +- `tests/Scaffold/Cli/OpenApiImportExtensionsTest.php` - `tests/Scaffold/Cli/OpenApiImportRunnerTest.php` - `tests/Scaffold/Cli/OpenApiImportScaffoldTest.php` - `tests/Scaffold/Cli/PersistenceInferrerTest.php` @@ -106,6 +107,7 @@ - `tests/Scaffold/Emitter/MessageEmitterTest.php` - `tests/Scaffold/Emitter/MigrationEmitterTest.php` - `tests/Scaffold/Emitter/NamingTest.php` +- `tests/Scaffold/Emitter/OpenApiEmitterExtensionsTest.php` - `tests/Scaffold/Emitter/OpenApiEmitterTest.php` - `tests/Scaffold/Emitter/RepositoryEmitterTest.php` - `tests/Scaffold/Emitter/ResponderEmitterTest.php` @@ -120,11 +122,13 @@ - `tests/Scaffold/Sdk/CompileIntegrationTest.php` - `tests/Scaffold/Sdk/EmitSdkCommandTest.php` - `tests/Scaffold/Sdk/EmitterRegistryTest.php` +- `tests/Scaffold/Sdk/OpenApiParserExtensionsTest.php` - `tests/Scaffold/Sdk/OpenApiParserTest.php` - `tests/Scaffold/Sdk/PythonEmitterTest.php` - `tests/Scaffold/Sdk/TypeScriptEmitterTest.php` - `tests/Scaffold/Spec/Emitter/EmittedSpecTest.php` - `tests/Scaffold/Spec/Emitter/EmitterTest.php` +- `tests/Scaffold/Spec/Emitter/OperationMapperExtensionsTest.php` - `tests/Scaffold/Spec/Emitter/OperationMapperTest.php` - `tests/Scaffold/Spec/Emitter/PathDeriverTest.php` - `tests/Scaffold/Spec/Emitter/SchemaMapperTest.php` diff --git a/docs/openapi/extensions.md b/docs/openapi/extensions.md new file mode 100644 index 00000000..98b5922e --- /dev/null +++ b/docs/openapi/extensions.md @@ -0,0 +1,165 @@ +# `x-altair-*` OpenAPI extensions + +> The set of OpenAPI 3.1 specification extensions Univeros uses to carry +> framework-specific concerns that the base spec cannot express. Forward +> emit (`spec:emit-openapi`) writes them; reverse import +> (`openapi:import`) reads them back. The round-trip preserves +> persistence, queue, and domain identity that would otherwise be lost. + +**Schemas:** [`docs/openapi/extensions/`](./extensions/) +**Issue:** [#163](https://github.com/univeros/framework/issues/163) · +epic [#160](https://github.com/univeros/framework/issues/160) + +## Why an extension family + +OpenAPI 3.1 describes the wire shape — what the request looks like, what +each response looks like, what each status means. It deliberately does +not describe how that shape is satisfied: which class handles the +request, which entity persists it, which message gets dispatched after. + +For Univeros those are first-class spec fields. If a round trip through +OpenAPI silently dropped them, the only safe import workflow would be +"import then hand-rewrite," and the import path stops being useful for +adoption. `x-altair-*` is the OpenAPI-idiomatic way out: unknown `x-*` +keys are explicitly permitted by the spec and ignored by tooling that +doesn't recognise them, so adding ours doesn't break the document for +non-Univeros consumers. + +## The v1 keys + +All keys live at the **operation** level (under +`paths..`). + +| Key | Round-trips | Schema | +|---|---|---| +| `x-altair-domain` | Yes — `spec.domain.{class, invocation}` | [x-altair-domain.schema.json](./extensions/x-altair-domain.schema.json) | +| `x-altair-persistence` | Yes — `spec.persistence` | [x-altair-persistence.schema.json](./extensions/x-altair-persistence.schema.json) | +| `x-altair-queue` | Yes — `spec.queue` | [x-altair-queue.schema.json](./extensions/x-altair-queue.schema.json) | +| `x-altair-idempotency` | Carried through; runtime ships later | [x-altair-idempotency.schema.json](./extensions/x-altair-idempotency.schema.json) | +| `x-altair-webhook` | Carried through; runtime ships later | [x-altair-webhook.schema.json](./extensions/x-altair-webhook.schema.json) | +| `x-altair-input-location` | Carried through; needs parameters-parser support | [x-altair-input-location.schema.json](./extensions/x-altair-input-location.schema.json) | + +"Carried through" means the parser preserves the key on the +`OperationModel` so a downstream emitter can read it. The reverse +importer doesn't yet do anything with it because the corresponding +runtime piece hasn't landed; the schema is published now so authoring +tooling can lint a document that uses it. + +## Round-trip example + +A hand-authored Altair YAML spec: + +```yaml +endpoint: + method: POST + path: /users + summary: Create a user + tags: [users] +input: + email: + type: string + rules: [required] +domain: + class: App\User\CreateUser + invocation: __invoke +persistence: + entity: + class: App\User\User + table: users + fields: + id: { type: uuid, primary: true } + email: { type: string, unique: true } + repository: App\User\UserRepository +queue: + on_create: + message: App\Messages\SendWelcomeEmail + fields: { email: string } + transport: redis +``` + +`spec:emit-openapi` produces the corresponding OpenAPI 3.1 fragment with +the `x-altair-*` blocks attached: + +```yaml +paths: + /users: + post: + summary: Create a user + tags: [users] + x-altair-domain: + class: App\User\CreateUser + invocation: __invoke + x-altair-persistence: + entity: + class: App\User\User + table: users + fields: + id: { type: uuid, primary: true } + email: { type: string, unique: true } + repository: App\User\UserRepository + x-altair-queue: + - name: on_create + message: App\Messages\SendWelcomeEmail + fields: { email: string } + transport: redis + requestBody: + ... + responses: + ... +``` + +`openapi:import` reads those blocks and reconstructs the original spec: +`domain.class` matches (not the path-derived +`App\Users\CreateUsers`), the `persistence:` block is recovered +verbatim, and the `queue:` block comes back as a name-keyed map (the +extension is a list because OpenAPI's YAML serialisation reads more +naturally that way; the Altair spec uses a map because the dispatch +name is a stable identifier). + +## Forward compatibility + +Unknown `x-altair-*` keys are not an error. The parser captures any key +that starts with `x-altair-` onto `OperationModel::$extensions`; on the +reverse path, anything the runner does not know how to interpret +surfaces in `ImportReceipt::$warnings` so v1 imports never silently +drop a key a future Univeros release will rely on. + +The warning is informational: the receipt's `ok` field stays `true` and +the import still succeeds. Agents can branch on the warning to refuse +imports that depend on yet-unsupported behaviour, or note the gap and +proceed. + +## Validating the extensions + +The schemas in [`docs/openapi/extensions/`](./extensions/) are Draft +2020-12 JSON Schemas. They can be used in two ways: + +1. **At authoring time.** Editor tooling that supports + `$schema`-referencing extensions can validate `x-altair-*` blocks + inline as the document is edited. +2. **At CI time.** A linter step in the OpenAPI document's repository + can validate each `x-altair-*` block against the matching schema and + fail the build on drift — the same gate the round-trip test + ([#164](https://github.com/univeros/framework/issues/164)) provides + from the framework side. + +## What does not round-trip yet + +- **`x-altair-input-location`**. The Altair flat `input:` block can + represent path / query / header / body inputs uniformly, but the + `OpenApiParser` does not currently parse `parameters[]` schemas, so + the location annotation has nowhere to land on the reverse path. The + forward emitter does not yet write this key either — both halves + land together when the parser gains `parameters[]` support. +- **`x-altair-idempotency`** / **`x-altair-webhook`**. The framework + pieces these refer to (idempotency middleware, the webhook + receiver / dispatcher) ship under separate issues. The keys are + reserved and the schemas are published so the wire format stays + stable across releases. + +## See also + +- [docs/openapi/import.md](./import.md) — the importer that consumes these keys +- [#162](https://github.com/univeros/framework/issues/162) — the CLI itself +- [#161](https://github.com/univeros/framework/issues/161) — the spec emitter (library) +- [#164](https://github.com/univeros/framework/issues/164) — round-trip drift gate diff --git a/docs/openapi/extensions/x-altair-domain.schema.json b/docs/openapi/extensions/x-altair-domain.schema.json new file mode 100644 index 00000000..9d5cacd1 --- /dev/null +++ b/docs/openapi/extensions/x-altair-domain.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://univeros.io/openapi/extensions/x-altair-domain.schema.json", + "title": "x-altair-domain", + "description": "Carries the FQCN of the domain service that handles this operation, so that openapi:import recovers the original spec's domain.class instead of synthesising one from the path + operationId.", + "type": "object", + "additionalProperties": false, + "required": ["class"], + "properties": { + "class": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)+$", + "description": "Fully-qualified PHP class name of the domain service, e.g. App\\User\\CreateUser." + }, + "invocation": { + "type": "string", + "default": "__invoke", + "description": "Method on the domain class to invoke; defaults to __invoke." + } + } +} diff --git a/docs/openapi/extensions/x-altair-idempotency.schema.json b/docs/openapi/extensions/x-altair-idempotency.schema.json new file mode 100644 index 00000000..d987b7a9 --- /dev/null +++ b/docs/openapi/extensions/x-altair-idempotency.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://univeros.io/openapi/extensions/x-altair-idempotency.schema.json", + "title": "x-altair-idempotency", + "description": "Reserved key for the idempotency primitive arriving with the dedicated framework work. Emitted by spec:emit-openapi when the spec carries an idempotency block; imported as-is on the reverse path. This release does not yet wire the runtime.", + "type": "object", + "additionalProperties": false, + "required": ["ttl"], + "properties": { + "ttl": { + "type": "string", + "description": "Duration the framework should remember the Idempotency-Key for, e.g. 24h, 7d.", + "pattern": "^[0-9]+(ms|s|m|h|d)$" + }, + "scope": { + "type": "string", + "description": "Logical bucket the key is scoped to (e.g. tenant, user, global). Application-defined.", + "default": "tenant" + } + } +} diff --git a/docs/openapi/extensions/x-altair-input-location.schema.json b/docs/openapi/extensions/x-altair-input-location.schema.json new file mode 100644 index 00000000..002ea5bc --- /dev/null +++ b/docs/openapi/extensions/x-altair-input-location.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://univeros.io/openapi/extensions/x-altair-input-location.schema.json", + "title": "x-altair-input-location", + "description": "Per-input-field annotation that records where the value came from on the wire (path, query, header) so an imported spec preserves the location even though Altair's input: block stores all inputs flat. Reserved key — OpenApiParser does not yet read parameters[] schemas, so the round-trip is incomplete in this release; the schema is published so authoring tooling can lint a doc that uses it.", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["in"], + "properties": { + "in": { + "type": "string", + "enum": ["path", "query", "header", "body"] + }, + "name": { + "type": "string", + "description": "Override the field name as it appears on the wire (e.g. snake_case ↔ camelCase mapping)." + } + } + } +} diff --git a/docs/openapi/extensions/x-altair-persistence.schema.json b/docs/openapi/extensions/x-altair-persistence.schema.json new file mode 100644 index 00000000..41c59d1c --- /dev/null +++ b/docs/openapi/extensions/x-altair-persistence.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://univeros.io/openapi/extensions/x-altair-persistence.schema.json", + "title": "x-altair-persistence", + "description": "Mirrors the Altair spec's persistence: block at the operation level so the imported entity + table + fields survive a round trip through OpenAPI 3.1.", + "type": "object", + "additionalProperties": false, + "required": ["entity"], + "properties": { + "entity": { + "type": "object", + "additionalProperties": false, + "required": ["class", "table", "fields"], + "properties": { + "class": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)+$" + }, + "table": { + "type": "string", + "minLength": 1 + }, + "fields": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "uuid", "string", "text", "int", "integer", + "bigint", "smallint", "float", "decimal", + "bool", "boolean", "datetime", "date", "time", + "json", "enum" + ] + }, + "primary": { "type": "boolean" }, + "nullable": { "type": "boolean" }, + "unique": { "type": "boolean" }, + "default": {}, + "of": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)+$", + "description": "FQCN of the PHP enum for type=enum fields." + } + } + } + } + } + }, + "repository": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)+$" + } + } +} diff --git a/docs/openapi/extensions/x-altair-queue.schema.json b/docs/openapi/extensions/x-altair-queue.schema.json new file mode 100644 index 00000000..0ebd74d5 --- /dev/null +++ b/docs/openapi/extensions/x-altair-queue.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://univeros.io/openapi/extensions/x-altair-queue.schema.json", + "title": "x-altair-queue", + "description": "Mirrors the Altair spec's queue: block, capturing one or more message dispatches per operation. Each entry is the same shape the Symfony Messenger handler factory consumes downstream.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "message", "fields"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Dispatch handle, e.g. on_create. Keys the message back to the queue: map after import." + }, + "message": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)+$", + "description": "FQCN of the Message DTO to dispatch." + }, + "fields": { + "type": "object", + "description": "Field name => PHP-ish type (string|int|float|bool) or FQCN.", + "additionalProperties": { + "type": "string" + } + }, + "transport": { + "type": "string", + "description": "Symfony Messenger transport name; null = bus default routing." + } + } + } +} diff --git a/docs/openapi/extensions/x-altair-webhook.schema.json b/docs/openapi/extensions/x-altair-webhook.schema.json new file mode 100644 index 00000000..14f6b747 --- /dev/null +++ b/docs/openapi/extensions/x-altair-webhook.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://univeros.io/openapi/extensions/x-altair-webhook.schema.json", + "title": "x-altair-webhook", + "description": "Reserved key for the webhook primitive arriving with the dedicated framework work. Marks an operation as an inbound (in) or outbound (out) webhook endpoint and carries the verification settings the framework will need at runtime. This release does not yet wire the runtime.", + "type": "object", + "additionalProperties": false, + "required": ["direction", "signing"], + "properties": { + "direction": { + "type": "string", + "enum": ["in", "out"] + }, + "signing": { + "type": "string", + "description": "Signing scheme expected on inbound requests / applied to outbound dispatches.", + "enum": ["hmac-sha256", "hmac-sha512", "ed25519"] + }, + "header": { + "type": "string", + "description": "Header name carrying the signature (inbound). Defaults to X-Signature." + }, + "dedupe_ttl": { + "type": "string", + "pattern": "^[0-9]+(ms|s|m|h|d)$", + "description": "How long to remember the request ID for replay protection." + } + } +} diff --git a/src/Altair/Scaffold/Cli/OpenApiImportRunner.php b/src/Altair/Scaffold/Cli/OpenApiImportRunner.php index 888d9a24..12eeac0b 100644 --- a/src/Altair/Scaffold/Cli/OpenApiImportRunner.php +++ b/src/Altair/Scaffold/Cli/OpenApiImportRunner.php @@ -62,6 +62,21 @@ private const int YAML_INDENT = 2; + /** + * Operation-level `x-altair-*` keys this release understands. Anything + * outside this list still rides along verbatim — the parser captures + * any `x-altair-*` key — but surfaces in the receipt's `warnings[]` + * so v1 imports do not silently drop a key a future release relies on. + */ + private const array KNOWN_OPERATION_EXTENSIONS = [ + 'x-altair-domain', + 'x-altair-persistence', + 'x-altair-queue', + 'x-altair-idempotency', + 'x-altair-webhook', + 'x-altair-input-location', + ]; + public function __construct( private OpenApiParser $parser = new OpenApiParser(), private OperationMapper $operationMapper = new OperationMapper(), @@ -101,7 +116,7 @@ public function run(OpenApiImportOptions $options): ImportReceipt } if ($options->dryRun) { - return $this->dryRunReceipt($options, $planned); + return $this->dryRunReceipt($options, $planned, $document); } $collector = new SnapshotCollector($options->projectRoot); @@ -110,7 +125,7 @@ public function run(OpenApiImportOptions $options): ImportReceipt $writtenSpecs = $this->writeSpecs($planned, $writer, $collector, $options->force); $scaffoldFiles = []; $rolledBack = []; - $warnings = $this->initialWarnings($options); + $warnings = [...$this->initialWarnings($options), ...$this->unknownExtensionWarnings($document)]; if ($options->scaffold && $writtenSpecs !== []) { try { @@ -266,7 +281,7 @@ private function initialWarnings(OpenApiImportOptions $options): array $warnings = []; if ($options->queue !== null) { $warnings[] = \sprintf( - "queue=%s recorded but inert: x-altair-queue extension support lands in #163; the flag is preserved for forward compatibility.", + "queue=%s flag recorded; x-altair-queue blocks in the OpenAPI source still round-trip into spec.queue regardless of the flag.", $options->queue, ); } @@ -274,6 +289,38 @@ private function initialWarnings(OpenApiImportOptions $options): array return $warnings; } + /** + * @return list + */ + private function unknownExtensionWarnings(OpenApiDocument $document): array + { + $warnings = []; + $seen = []; + foreach ($document->operations as $operation) { + foreach ($operation->extensions as $key => $_value) { + if (!\is_string($key)) { + continue; + } + + if (isset($seen[$key])) { + continue; + } + + if (\in_array($key, self::KNOWN_OPERATION_EXTENSIONS, true)) { + continue; + } + + $seen[$key] = true; + $warnings[] = \sprintf( + "unknown extension '%s' carried through unchanged; not interpreted by this release.", + $key, + ); + } + } + + return $warnings; + } + private function recordJournal(OpenApiImportOptions $options, string $sourceContents, SnapshotCollector $collector): ?string { if (!$this->journal instanceof Journal) { @@ -362,9 +409,9 @@ private function readDocument(string $path): ?string } /** - * @param list $planned + * @param list $planned */ - private function dryRunReceipt(OpenApiImportOptions $options, array $planned): ImportReceipt + private function dryRunReceipt(OpenApiImportOptions $options, array $planned, OpenApiDocument $document): ImportReceipt { return new ImportReceipt( ok: true, @@ -374,7 +421,7 @@ private function dryRunReceipt(OpenApiImportOptions $options, array $planned): I scaffolded: [], rolledBack: [], unmapped: [], - warnings: $this->initialWarnings($options), + warnings: [...$this->initialWarnings($options), ...$this->unknownExtensionWarnings($document)], journalId: null, eventId: null, error: null, diff --git a/src/Altair/Scaffold/Emitter/OpenApiEmitter.php b/src/Altair/Scaffold/Emitter/OpenApiEmitter.php index 6a79dd2e..97dfd3dd 100644 --- a/src/Altair/Scaffold/Emitter/OpenApiEmitter.php +++ b/src/Altair/Scaffold/Emitter/OpenApiEmitter.php @@ -12,6 +12,9 @@ namespace Altair\Scaffold\Emitter; use Altair\Scaffold\Spec\Ast\OutputResponseSpec; +use Altair\Scaffold\Spec\Ast\PersistenceFieldSpec; +use Altair\Scaffold\Spec\Ast\PersistenceSpec; +use Altair\Scaffold\Spec\Ast\QueueDispatchSpec; use Altair\Scaffold\Spec\Ast\Spec; use Symfony\Component\Yaml\Yaml; @@ -62,6 +65,10 @@ private function renderOperation(Spec $spec): array 'tags' => $spec->endpoint->tags, ]; + foreach ($this->renderAltairExtensions($spec) as $key => $value) { + $operation[$key] = $value; + } + if ($spec->inputs !== []) { $properties = []; $required = []; @@ -137,6 +144,110 @@ private function renderBodySchema(array $body): array return ['type' => 'object', 'properties' => $properties]; } + /** + * Round-trippable `x-altair-*` blocks carrying spec fields OpenAPI 3.1 + * cannot natively express. Lets `openapi:import` recover the original + * `domain:`, `persistence:`, and `queue:` blocks byte-for-byte instead + * of having to re-infer them from the path + response shape. + * + * @return array + */ + private function renderAltairExtensions(Spec $spec): array + { + $extensions = [ + 'x-altair-domain' => [ + 'class' => $spec->domain->class, + 'invocation' => $spec->domain->invocation, + ], + ]; + + if ($spec->persistence instanceof PersistenceSpec) { + $extensions['x-altair-persistence'] = $this->renderPersistence($spec->persistence); + } + + if ($spec->queue !== []) { + $extensions['x-altair-queue'] = array_values(array_map( + $this->renderQueueDispatch(...), + $spec->queue, + )); + } + + return $extensions; + } + + /** + * @return array + */ + private function renderPersistence(PersistenceSpec $persistence): array + { + $fields = []; + foreach ($persistence->entity->fields as $field) { + $fields[$field->name] = $this->renderPersistenceField($field); + } + + $block = [ + 'entity' => [ + 'class' => $persistence->entity->class, + 'table' => $persistence->entity->table, + 'fields' => $fields, + ], + ]; + + if ($persistence->repository !== '') { + $block['repository'] = $persistence->repository; + } + + return $block; + } + + /** + * @return array + */ + private function renderPersistenceField(PersistenceFieldSpec $field): array + { + $rendered = ['type' => $field->type]; + + if ($field->primary) { + $rendered['primary'] = true; + } + + if ($field->nullable) { + $rendered['nullable'] = true; + } + + if ($field->unique) { + $rendered['unique'] = true; + } + + if ($field->hasDefault) { + $rendered['default'] = $field->default; + } + + if ($field->of !== null) { + $rendered['of'] = $field->of; + } + + return $rendered; + } + + /** + * @return array + */ + private function renderQueueDispatch(QueueDispatchSpec $dispatch): array + { + $rendered = [ + 'name' => $dispatch->name, + 'message' => $dispatch->message, + 'fields' => $dispatch->fields, + ]; + + if ($dispatch->transport !== null) { + $rendered['transport'] = $dispatch->transport; + } + + return $rendered; + } + /** * @return array */ diff --git a/src/Altair/Scaffold/Sdk/Model/OpenApiParser.php b/src/Altair/Scaffold/Sdk/Model/OpenApiParser.php index 54a06dec..ce99ef0c 100644 --- a/src/Altair/Scaffold/Sdk/Model/OpenApiParser.php +++ b/src/Altair/Scaffold/Sdk/Model/OpenApiParser.php @@ -136,9 +136,32 @@ private function parseOperation(string $path, string $method, array $operation): requestBody: $requestBody, responses: $responses, summary: isset($operation['summary']) && \is_string($operation['summary']) ? $operation['summary'] : '', + extensions: $this->extractExtensions($operation), ); } + /** + * `x-altair-*` keys carried at the operation level are preserved + * verbatim so the import path can round-trip framework-specific + * concerns (domain class, persistence, queue) that OpenAPI itself + * cannot express. Unknown extension keys still ride along so a v2 + * extension can't be stripped by a v1 parser. + * + * @param array $operation + * @return array + */ + private function extractExtensions(array $operation): array + { + $extensions = []; + foreach ($operation as $key => $value) { + if (\is_string($key) && str_starts_with($key, 'x-altair-')) { + $extensions[$key] = $value; + } + } + + return $extensions; + } + /** * @param array $operation * diff --git a/src/Altair/Scaffold/Sdk/Model/OperationModel.php b/src/Altair/Scaffold/Sdk/Model/OperationModel.php index 692fef64..0792ee8e 100644 --- a/src/Altair/Scaffold/Sdk/Model/OperationModel.php +++ b/src/Altair/Scaffold/Sdk/Model/OperationModel.php @@ -24,6 +24,7 @@ /** * @param list $pathParameters Names of `{param}` path segments, in order. * @param list $responses + * @param array $extensions `x-altair-*` keys carried verbatim from the OpenAPI document. */ public function __construct( public string $operationId, @@ -33,6 +34,7 @@ public function __construct( public ?SchemaType $requestBody, public array $responses, public string $summary = '', + public array $extensions = [], ) {} public function hasRequestBody(): bool diff --git a/src/Altair/Scaffold/Spec/Emitter/OperationMapper.php b/src/Altair/Scaffold/Spec/Emitter/OperationMapper.php index b4d90d16..9aefd273 100644 --- a/src/Altair/Scaffold/Spec/Emitter/OperationMapper.php +++ b/src/Altair/Scaffold/Spec/Emitter/OperationMapper.php @@ -45,12 +45,82 @@ public function map(OpenApiDocument $document, OperationModel $operation): array $spec['output'] = $this->outputBlock($outputs); } - $spec['domain'] = [ + $spec['domain'] = $this->resolveDomain($operation); + + $persistence = $this->extensionMap($operation, 'x-altair-persistence'); + if ($persistence !== null) { + $spec['persistence'] = $persistence; + } + + $queue = $this->extensionMap($operation, 'x-altair-queue'); + if ($queue !== null) { + $spec['queue'] = $this->renderQueue($queue); + } + + return $spec; + } + + /** + * Pulls `x-altair-domain` when present so an imported endpoint keeps + * the FQCN its original spec carried. Falls back to {@see PathDeriver} + * when the extension is absent. + * + * @return array + */ + private function resolveDomain(OperationModel $operation): array + { + $extension = $operation->extensions['x-altair-domain'] ?? null; + if (\is_array($extension) && isset($extension['class']) && \is_string($extension['class']) && $extension['class'] !== '') { + $invocation = isset($extension['invocation']) && \is_string($extension['invocation']) && $extension['invocation'] !== '' + ? $extension['invocation'] + : '__invoke'; + + return ['class' => $extension['class'], 'invocation' => $invocation]; + } + + return [ 'class' => $this->pathDeriver->domainFqcn($operation), 'invocation' => '__invoke', ]; + } - return $spec; + /** + * @return ?array + */ + private function extensionMap(OperationModel $operation, string $key): ?array + { + $value = $operation->extensions[$key] ?? null; + + return \is_array($value) ? $value : null; + } + + /** + * Turns the list form `x-altair-queue: [{name, message, ...}]` carried + * in the OpenAPI extension back into the map form + * `queue: { name: { message, ... } }` the Altair Parser expects. + * + * @param array $value + * @return array> + */ + private function renderQueue(array $value): array + { + $result = []; + foreach ($value as $entry) { + if (!\is_array($entry)) { + continue; + } + + $name = isset($entry['name']) && \is_string($entry['name']) ? $entry['name'] : null; + if ($name === null) { + continue; + } + + $rendered = $entry; + unset($rendered['name']); + $result[$name] = $rendered; + } + + return $result; } /** diff --git a/tests/Scaffold/Cli/OpenApiImportExtensionsTest.php b/tests/Scaffold/Cli/OpenApiImportExtensionsTest.php new file mode 100644 index 00000000..8e9d4b6d --- /dev/null +++ b/tests/Scaffold/Cli/OpenApiImportExtensionsTest.php @@ -0,0 +1,205 @@ +sandbox = sys_get_temp_dir() . '/altair-extensions-' . bin2hex(random_bytes(4)); + mkdir($this->sandbox, 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->sandbox)) { + $this->removeRecursively($this->sandbox); + } + } + + public function testXAltairDomainSurvivesImport(): void + { + $documentPath = $this->writeDocWithExtensions(); + + $receipt = (new OpenApiImportRunner())->run(new OpenApiImportOptions( + documentPath: $documentPath, + projectRoot: $this->sandbox, + )); + + self::assertTrue($receipt->ok); + $spec = Yaml::parseFile($this->sandbox . '/api/users/create.yaml'); + self::assertSame('Acme\\User\\HandleCreate', $spec['domain']['class']); + self::assertSame('handle', $spec['domain']['invocation']); + } + + public function testXAltairPersistenceSurvivesImport(): void + { + $documentPath = $this->writeDocWithExtensions(); + + (new OpenApiImportRunner())->run(new OpenApiImportOptions( + documentPath: $documentPath, + projectRoot: $this->sandbox, + )); + + $spec = Yaml::parseFile($this->sandbox . '/api/users/create.yaml'); + self::assertArrayHasKey('persistence', $spec); + self::assertSame('Acme\\User\\User', $spec['persistence']['entity']['class']); + self::assertSame('user_records', $spec['persistence']['entity']['table']); + self::assertSame('uuid', $spec['persistence']['entity']['fields']['id']['type']); + } + + public function testXAltairQueueSurvivesImportAsKeyedMap(): void + { + $documentPath = $this->writeDocWithExtensions(); + + (new OpenApiImportRunner())->run(new OpenApiImportOptions( + documentPath: $documentPath, + projectRoot: $this->sandbox, + )); + + $spec = Yaml::parseFile($this->sandbox . '/api/users/create.yaml'); + self::assertArrayHasKey('queue', $spec); + self::assertArrayHasKey('on_create', $spec['queue']); + self::assertSame('Acme\\Msg\\Welcome', $spec['queue']['on_create']['message']); + } + + public function testUnknownExtensionSurfacesWarning(): void + { + $documentPath = $this->sandbox . '/openapi.yaml'; + file_put_contents($documentPath, <<<'YAML' + openapi: 3.1.0 + info: { title: X, version: 1.0 } + paths: + /things: + get: + operationId: listThings + x-altair-future-feature: { hint: yes } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + count: { type: integer } + YAML); + + $receipt = (new OpenApiImportRunner())->run(new OpenApiImportOptions( + documentPath: $documentPath, + projectRoot: $this->sandbox, + )); + + self::assertTrue($receipt->ok); + self::assertNotEmpty($receipt->warnings); + $matched = array_filter( + $receipt->warnings, + static fn(string $w): bool => str_contains($w, 'x-altair-future-feature'), + ); + self::assertNotEmpty($matched, 'expected a warning for x-altair-future-feature, got: ' . implode('; ', $receipt->warnings)); + } + + public function testKnownExtensionsDoNotEmitUnknownWarning(): void + { + $documentPath = $this->writeDocWithExtensions(); + + $receipt = (new OpenApiImportRunner())->run(new OpenApiImportOptions( + documentPath: $documentPath, + projectRoot: $this->sandbox, + )); + + $unknownWarnings = array_filter( + $receipt->warnings, + static fn(string $w): bool => str_contains($w, 'unknown extension'), + ); + + self::assertSame([], $unknownWarnings, 'Known extensions should not trigger unknown warnings; got: ' . implode('; ', $receipt->warnings)); + } + + private function writeDocWithExtensions(): string + { + $path = $this->sandbox . '/openapi.yaml'; + file_put_contents($path, <<<'YAML' + openapi: 3.1.0 + info: + title: Users API + version: 1.0.0 + paths: + /users: + post: + operationId: createUser + summary: Create a user with non-standard hand-edits preserved + x-altair-domain: + class: Acme\User\HandleCreate + invocation: handle + x-altair-persistence: + entity: + class: Acme\User\User + table: user_records + fields: + id: { type: uuid, primary: true } + email: { type: string, unique: true } + repository: Acme\User\UserRepository + x-altair-queue: + - name: on_create + message: Acme\Msg\Welcome + fields: { id: string, email: string } + transport: redis + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string } + responses: + '201': + description: Created + content: + application/json: + schema: + type: object + properties: + id: { type: string } + YAML); + + return $path; + } + + private function removeRecursively(string $path): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $node) { + if ($node->isDir()) { + @rmdir($node->getPathname()); + } else { + @unlink($node->getPathname()); + } + } + + @rmdir($path); + } +} diff --git a/tests/Scaffold/Emitter/OpenApiEmitterExtensionsTest.php b/tests/Scaffold/Emitter/OpenApiEmitterExtensionsTest.php new file mode 100644 index 00000000..a88d5e91 --- /dev/null +++ b/tests/Scaffold/Emitter/OpenApiEmitterExtensionsTest.php @@ -0,0 +1,72 @@ +emit(SpecFixture::createUser()); + $doc = Yaml::parse($file->contents); + + $operation = $doc['paths']['/users']['post']; + self::assertArrayHasKey('x-altair-domain', $operation); + self::assertSame('App\\User\\CreateUser', $operation['x-altair-domain']['class']); + self::assertSame('__invoke', $operation['x-altair-domain']['invocation']); + } + + public function testEmitsXAltairPersistenceBlock(): void + { + $file = (new OpenApiEmitter())->emit(SpecFixture::createUserWithPersistence()); + $doc = Yaml::parse($file->contents); + + $operation = $doc['paths']['/users']['post']; + self::assertArrayHasKey('x-altair-persistence', $operation); + + $persistence = $operation['x-altair-persistence']; + self::assertSame('App\\User\\User', $persistence['entity']['class']); + self::assertSame('users', $persistence['entity']['table']); + self::assertSame('App\\User\\UserRepository', $persistence['repository']); + + $fields = $persistence['entity']['fields']; + self::assertSame('uuid', $fields['id']['type']); + self::assertTrue($fields['id']['primary']); + self::assertSame('string', $fields['email']['type']); + self::assertTrue($fields['email']['unique']); + self::assertSame('datetime', $fields['created_at']['type']); + self::assertSame('now', $fields['created_at']['default']); + } + + public function testEmitsXAltairQueueBlock(): void + { + $file = (new OpenApiEmitter())->emit(SpecFixture::createUserWithQueue()); + $doc = Yaml::parse($file->contents); + + $operation = $doc['paths']['/users']['post']; + self::assertArrayHasKey('x-altair-queue', $operation); + + $queue = $operation['x-altair-queue']; + self::assertCount(1, $queue); + self::assertSame('on_create', $queue[0]['name']); + self::assertSame('App\\Messages\\SendWelcomeEmail', $queue[0]['message']); + self::assertSame('default', $queue[0]['transport']); + self::assertSame(['userId' => 'string', 'email' => 'string'], $queue[0]['fields']); + } + + public function testOmitsBlocksWhenSpecHasNeither(): void + { + $file = (new OpenApiEmitter())->emit(SpecFixture::createUser()); + $doc = Yaml::parse($file->contents); + + $operation = $doc['paths']['/users']['post']; + self::assertArrayNotHasKey('x-altair-persistence', $operation); + self::assertArrayNotHasKey('x-altair-queue', $operation); + } +} diff --git a/tests/Scaffold/Sdk/OpenApiParserExtensionsTest.php b/tests/Scaffold/Sdk/OpenApiParserExtensionsTest.php new file mode 100644 index 00000000..158c3c27 --- /dev/null +++ b/tests/Scaffold/Sdk/OpenApiParserExtensionsTest.php @@ -0,0 +1,59 @@ +parseYaml(<<<'YAML' + openapi: 3.1.0 + info: { title: X, version: 1.0 } + paths: + /users: + post: + operationId: createUser + x-altair-domain: + class: App\User\CreateUser + invocation: __invoke + x-altair-persistence: + entity: + class: App\User\User + table: users + fields: + id: { type: uuid, primary: true } + x-other: ignored + responses: + '201': { description: ok } + YAML); + + $operation = $document->operations[0]; + self::assertArrayHasKey('x-altair-domain', $operation->extensions); + self::assertSame('App\\User\\CreateUser', $operation->extensions['x-altair-domain']['class']); + self::assertArrayHasKey('x-altair-persistence', $operation->extensions); + self::assertArrayNotHasKey('x-other', $operation->extensions); + } + + public function testCarriesUnknownAltairExtensions(): void + { + $document = (new OpenApiParser())->parseYaml(<<<'YAML' + openapi: 3.1.0 + info: { title: X, version: 1.0 } + paths: + /pings: + get: + operationId: ping + x-altair-future: { foo: bar } + responses: + '200': { description: ok } + YAML); + + self::assertArrayHasKey('x-altair-future', $document->operations[0]->extensions); + self::assertSame(['foo' => 'bar'], $document->operations[0]->extensions['x-altair-future']); + } +} diff --git a/tests/Scaffold/Snapshots/create-user.openapi.yaml b/tests/Scaffold/Snapshots/create-user.openapi.yaml index 8fa396a8..afcec58a 100644 --- a/tests/Scaffold/Snapshots/create-user.openapi.yaml +++ b/tests/Scaffold/Snapshots/create-user.openapi.yaml @@ -8,6 +8,9 @@ paths: summary: 'Create a new user' tags: - users + x-altair-domain: + class: App\User\CreateUser + invocation: __invoke requestBody: required: true content: diff --git a/tests/Scaffold/Spec/Emitter/OperationMapperExtensionsTest.php b/tests/Scaffold/Spec/Emitter/OperationMapperExtensionsTest.php new file mode 100644 index 00000000..5924f4a8 --- /dev/null +++ b/tests/Scaffold/Spec/Emitter/OperationMapperExtensionsTest.php @@ -0,0 +1,125 @@ + ['class' => 'Acme\\Custom\\HandleCreate', 'invocation' => 'handle'], + ], + ); + + $spec = (new OperationMapper())->map($this->emptyDocument(), $operation); + + self::assertSame('Acme\\Custom\\HandleCreate', $spec['domain']['class']); + self::assertSame('handle', $spec['domain']['invocation']); + } + + public function testXAltairDomainInvocationDefaults(): void + { + $operation = new OperationModel( + operationId: '', + method: 'POST', + path: '/users', + pathParameters: [], + requestBody: null, + responses: [], + extensions: [ + 'x-altair-domain' => ['class' => 'Acme\\Custom\\X'], + ], + ); + + $spec = (new OperationMapper())->map($this->emptyDocument(), $operation); + + self::assertSame('__invoke', $spec['domain']['invocation']); + } + + public function testXAltairPersistenceBlockIsCopiedIntoSpec(): void + { + $persistence = [ + 'entity' => [ + 'class' => 'App\\User\\User', + 'table' => 'users', + 'fields' => ['id' => ['type' => 'uuid', 'primary' => true]], + ], + 'repository' => 'App\\User\\UserRepository', + ]; + + $operation = new OperationModel( + operationId: '', + method: 'POST', + path: '/users', + pathParameters: [], + requestBody: null, + responses: [], + extensions: ['x-altair-persistence' => $persistence], + ); + + $spec = (new OperationMapper())->map($this->emptyDocument(), $operation); + + self::assertSame($persistence, $spec['persistence']); + } + + public function testXAltairQueueListIsRekeyedByName(): void + { + $queue = [ + ['name' => 'on_create', 'message' => 'App\\Msg\\Foo', 'fields' => ['id' => 'string'], 'transport' => 'default'], + ['name' => 'on_create_email', 'message' => 'App\\Msg\\Bar', 'fields' => []], + ]; + + $operation = new OperationModel( + operationId: '', + method: 'POST', + path: '/users', + pathParameters: [], + requestBody: null, + responses: [], + extensions: ['x-altair-queue' => $queue], + ); + + $spec = (new OperationMapper())->map($this->emptyDocument(), $operation); + + self::assertArrayHasKey('on_create', $spec['queue']); + self::assertSame('App\\Msg\\Foo', $spec['queue']['on_create']['message']); + self::assertSame('default', $spec['queue']['on_create']['transport']); + self::assertArrayHasKey('on_create_email', $spec['queue']); + self::assertArrayNotHasKey('name', $spec['queue']['on_create'], 'name should be a key, not a duplicated field'); + } + + public function testFallsBackToPathDeriverWhenExtensionAbsent(): void + { + $operation = new OperationModel( + operationId: 'createUser', + method: 'POST', + path: '/users', + pathParameters: [], + requestBody: null, + responses: [], + ); + + $spec = (new OperationMapper())->map($this->emptyDocument(), $operation); + + self::assertSame('App\\User\\CreateUser', $spec['domain']['class']); + } + + private function emptyDocument(): OpenApiDocument + { + return new OpenApiDocument(title: 'X', version: '1.0', operations: []); + } +}