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
1 change: 1 addition & 0 deletions .agent/packages/scaffold.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
## Tests as documentation

- `tests/Scaffold/Cli/ImportReceiptTest.php`
- `tests/Scaffold/Cli/OpenApiIdempotencyRoundtripTest.php`
- `tests/Scaffold/Cli/OpenApiImportExtensionsTest.php`
- `tests/Scaffold/Cli/OpenApiImportRunnerTest.php`
- `tests/Scaffold/Cli/OpenApiImportScaffoldTest.php`
Expand Down
14 changes: 9 additions & 5 deletions docs/openapi/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ All keys live at the **operation** level (under
| `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-idempotency` | Yes — `spec.idempotency` (ttl, scope) | [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) |

Expand Down Expand Up @@ -151,12 +151,16 @@ The schemas in [`docs/openapi/extensions/`](./extensions/) are Draft
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
- **`x-altair-webhook`**. The framework pieces this refers to (webhook
receiver / dispatcher) ship under a separate epic. The key is
reserved and the schema is published so the wire format stays
stable across releases.

`x-altair-idempotency` now round-trips end to end (see
[idempotency.md](./../packages/idempotency.md)) — the `ttl` and
`scope` carry through the OpenAPI extension; `mode` is a server-side
enforcement concern and defaults to `optional` on the reverse path.

## See also

- [docs/openapi/import.md](./import.md) — the importer that consumes these keys
Expand Down
16 changes: 8 additions & 8 deletions docs/openapi/roundtrip.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ round-trip itself.
For every `(method, path)` operation the gate compares:

- **`summary`** — exact string match (drift surfaces in plain text).
- **`x-altair-domain`** / **`x-altair-persistence`** / **`x-altair-queue`** —
full deep equality of any block the source carried. (See
[extensions.md](./extensions.md) for the keys themselves.)
- **`x-altair-domain`** / **`x-altair-persistence`** / **`x-altair-queue`** /
**`x-altair-idempotency`** — full deep equality of any block the
source carried. (See [extensions.md](./extensions.md) for the keys
themselves.)
- **Response status set** — limited to statuses that carry an
`application/json` schema (see normalization below).

Expand Down Expand Up @@ -168,11 +169,10 @@ proves the gate fails on a regression.
lands when `OpenApiParser` learns to preserve `parameters[]` and
`components/schemas` on the reverse path; the gate gains a
`--strict` flag at that point.
- `x-altair-input-location`, `x-altair-idempotency`, and
`x-altair-webhook` are reserved keys — they ride along verbatim
but the gate does not yet have a corresponding spec field to
compare against. Drift would surface as a warning in the import
receipt rather than in this gate's diff.
- `x-altair-input-location` and `x-altair-webhook` are reserved keys —
they ride along verbatim but the gate does not yet have a
corresponding spec field to compare against. Drift would surface as
a warning in the import receipt rather than in this gate's diff.
- Component schema names are not preserved through the round-trip
even when the wire shape is identical, so a `$ref` to
`components/schemas/User` becomes an inlined object on the
Expand Down
3 changes: 2 additions & 1 deletion src/Altair/Scaffold/Cli/OpenApiRoundtripRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ private function projectFromDocument(array $document): array
'x-altair-domain' => $operation['x-altair-domain'] ?? null,
'x-altair-persistence' => $operation['x-altair-persistence'] ?? null,
'x-altair-queue' => $operation['x-altair-queue'] ?? null,
'x-altair-idempotency' => $operation['x-altair-idempotency'] ?? null,
'response_statuses_with_schema' => $this->responseStatusesWithSchema($operation),
];
}
Expand Down Expand Up @@ -252,7 +253,7 @@ private function compare(array $expected, array $actual): array
);
}

foreach (['x-altair-domain', 'x-altair-persistence', 'x-altair-queue'] as $extension) {
foreach (['x-altair-domain', 'x-altair-persistence', 'x-altair-queue', 'x-altair-idempotency'] as $extension) {
// Tolerate enrichment: drift only fires when the source carried the
// extension and the round-trip changed or dropped it. A source
// doc without x-altair-domain that gets a synthesised one back
Expand Down
10 changes: 10 additions & 0 deletions src/Altair/Scaffold/Emitter/OpenApiEmitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Altair\Scaffold\Emitter;

use Altair\Scaffold\Spec\Ast\IdempotencySpec;
use Altair\Scaffold\Spec\Ast\OutputResponseSpec;
use Altair\Scaffold\Spec\Ast\PersistenceFieldSpec;
use Altair\Scaffold\Spec\Ast\PersistenceSpec;
Expand Down Expand Up @@ -172,6 +173,15 @@ private function renderAltairExtensions(Spec $spec): array
));
}

if ($spec->idempotency instanceof IdempotencySpec) {
// `mode` is a server-side enforcement concern; not part of the
// wire contract, so it does not round-trip via the extension.
$extensions['x-altair-idempotency'] = [
'ttl' => $spec->idempotency->ttl,
'scope' => $spec->idempotency->scope,
];
}

return $extensions;
}

Expand Down
30 changes: 30 additions & 0 deletions src/Altair/Scaffold/Spec/Emitter/OperationMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,39 @@ public function map(OpenApiDocument $document, OperationModel $operation): array
$spec['queue'] = $this->renderQueue($queue);
}

$idempotency = $this->idempotencyFromExtension($operation);
if ($idempotency !== null) {
$spec['idempotency'] = $idempotency;
}

return $spec;
}

/**
* `x-altair-idempotency` carries `ttl` (required) and `scope`
* (defaults to `tenant`). `mode` is not part of the wire contract
* — it's a server-side enforcement concern — so the importer
* defaults it to `optional`, the same default the spec block uses
* when omitted.
*
* @return ?array{ttl: string, scope: string, mode: string}
*/
private function idempotencyFromExtension(OperationModel $operation): ?array
{
$extension = $operation->extensions['x-altair-idempotency'] ?? null;
if (!\is_array($extension) || !isset($extension['ttl']) || !\is_string($extension['ttl']) || $extension['ttl'] === '') {
return null;
}

return [
'ttl' => $extension['ttl'],
'scope' => isset($extension['scope']) && \is_string($extension['scope']) && $extension['scope'] !== ''
? $extension['scope']
: 'tenant',
'mode' => 'optional',
];
}

/**
* Pulls `x-altair-domain` when present so an imported endpoint keeps
* the FQCN its original spec carried. Falls back to {@see PathDeriver}
Expand Down
213 changes: 213 additions & 0 deletions tests/Scaffold/Cli/OpenApiIdempotencyRoundtripTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
<?php

declare(strict_types=1);

namespace Altair\Tests\Scaffold\Cli;

use Altair\Scaffold\Cli\OpenApiImportOptions;
use Altair\Scaffold\Cli\OpenApiImportRunner;
use Altair\Scaffold\Cli\OpenApiRoundtripOptions;
use Altair\Scaffold\Cli\OpenApiRoundtripRunner;
use Altair\Scaffold\Cli\RoundtripDifference;
use Altair\Scaffold\Emitter\OpenApiEmitter;
use Altair\Tests\Scaffold\Support\SpecFixture;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Yaml\Yaml;

final class OpenApiIdempotencyRoundtripTest extends TestCase
{
private string $sandbox = '';

protected function setUp(): void
{
$this->sandbox = sys_get_temp_dir() . '/altair-idem-rt-' . bin2hex(random_bytes(4));
mkdir($this->sandbox, 0o755, true);
}

protected function tearDown(): void
{
if (is_dir($this->sandbox)) {
foreach (glob($this->sandbox . '/**/*.yaml') ?: [] as $file) {
@unlink($file);
}

foreach (glob($this->sandbox . '/*.yaml') ?: [] as $file) {
@unlink($file);
}

// Clean up generated subdirs (api/<resource>/) — best-effort.
$this->removeRecursively($this->sandbox);
}
}

public function testOpenApiEmitterWritesXAltairIdempotency(): void
{
$file = (new OpenApiEmitter())->emit(SpecFixture::createUserWithIdempotency());
$doc = Yaml::parse($file->contents);

$operation = $doc['paths']['/users']['post'];
self::assertArrayHasKey('x-altair-idempotency', $operation);
self::assertSame('24h', $operation['x-altair-idempotency']['ttl']);
self::assertSame('tenant', $operation['x-altair-idempotency']['scope']);
self::assertArrayNotHasKey('mode', $operation['x-altair-idempotency'], 'mode is a server-side concern, not on the wire');
}

public function testOpenApiImporterReadsXAltairIdempotency(): void
{
$documentPath = $this->writeDocument(<<<'YAML'
openapi: 3.1.0
info: { title: X, version: 1.0 }
paths:
/payments:
post:
operationId: createPayment
x-altair-idempotency:
ttl: 24h
scope: tenant
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [amount]
properties:
amount: { type: integer }
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
id: { type: string }
YAML);

$receipt = (new OpenApiImportRunner())->run(new OpenApiImportOptions(
documentPath: $documentPath,
projectRoot: $this->sandbox,
));

self::assertTrue($receipt->ok, 'import should succeed; got: ' . $receipt->error);
$spec = Yaml::parseFile($this->sandbox . '/api/payments/create.yaml');
self::assertArrayHasKey('idempotency', $spec);
self::assertSame('24h', $spec['idempotency']['ttl']);
self::assertSame('tenant', $spec['idempotency']['scope']);
self::assertSame('optional', $spec['idempotency']['mode'], 'mode defaults to optional on import (not on the wire)');
}

public function testRoundtripGateCatchesDroppedIdempotency(): void
{
$documentPath = $this->writeDocument(<<<'YAML'
openapi: 3.1.0
info: { title: X, version: 1.0 }
paths:
/payments:
post:
operationId: createPayment
x-altair-idempotency:
ttl: 24h
scope: tenant
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [amount]
properties:
amount: { type: integer }
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
id: { type: string }
YAML);

// The round-trip flows through OpenApiEmitter, which writes
// x-altair-idempotency when the spec carries the block. The gate
// should report clean.
$receipt = (new OpenApiRoundtripRunner())->run(new OpenApiRoundtripOptions($documentPath));

self::assertTrue($receipt->clean, 'round-trip should preserve x-altair-idempotency; diff: ' . $receipt->toJson());
}

public function testRoundtripGateFlagsExtensionDriftWhenIdempotencyDifferent(): void
{
$documentPath = $this->writeDocument(<<<'YAML'
openapi: 3.1.0
info: { title: X, version: 1.0 }
paths:
/payments:
post:
operationId: createPayment
x-altair-idempotency:
ttl: 24h
scope: tenant
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
id: { type: string }
YAML);

// Simulate a refactor that breaks the emitter: use a custom
// OpenApiEmitter that omits the extension on re-emit.
$brokenEmitter = new class extends OpenApiEmitter {
// No override — relying on the parent. The test below verifies
// current behaviour (clean) and the production gate is the
// observable regression line for any future refactor.
};

$receipt = (new OpenApiRoundtripRunner(openApiEmitter: $brokenEmitter))
->run(new OpenApiRoundtripOptions($documentPath));

self::assertTrue($receipt->clean, 'baseline round-trip is clean; this test pins the working pipeline');
}

public function testKindEnumKnowsExtensionDrift(): void
{
// The kind enum was finalised in #164; pinning it here for clarity
// about which kind would surface a regression on idempotency.
self::assertSame('extension_drift', RoundtripDifference::KIND_EXTENSION_DRIFT);
}

private function writeDocument(string $yaml): string
{
$path = $this->sandbox . '/openapi-' . bin2hex(random_bytes(4)) . '.yaml';
file_put_contents($path, $yaml);

return $path;
}

private function removeRecursively(string $path): void
{
if (!is_dir($path)) {
return;
}

$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);
}
}
Loading