From 2d47ecbff73af6f361b19ef9b9b9de399e11282d Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Sun, 31 May 2026 17:26:53 +0200 Subject: [PATCH] feat(scaffold): x-altair-webhook round-trip activation (#189) Activate the x-altair-webhook OpenAPI extension end-to-end, mirroring the x-altair-idempotency playbook (#175): - Forward emit: OpenApiEmitter::renderWebhook() writes the extension when a spec carries a webhook block. direction + signing always travel; every other field is omitted when it equals its WebhookSpec default, keeping the block minimal and byte-stable across the round-trip gate. - Reverse import: OperationMapper::webhookFromExtension() reconstructs the webhook: spec block, copying through only the non-default keys so the Parser re-applies the same defaults. - Drift gate: OpenApiRoundtripRunner compares x-altair-webhook in both the projection and the per-extension drift loop. A dropped/changed block now fails openapi:roundtrip with kind extension_drift. - Centralized WebhookSpec defaults into named constants so emit / parse / round-trip share one source of truth (no literal drift). - Expanded docs/openapi/extensions/x-altair-webhook.schema.json from the reserved 4-field stub to the full round-tripping contract. - Docs: extensions.md + roundtrip.md move webhook from "carried through" to "round-trips" and into the compared-set listing. Tests: 7 new cases covering inbound + outbound emit, default omission, reverse import, clean round-trip (both directions), and a real broken-emitter proving the drift gate fires. Regenerated .agent scaffold manifest. Closes #189 --- .agent/packages/scaffold.md | 1 + docs/openapi/extensions.md | 17 +- .../extensions/x-altair-webhook.schema.json | 49 ++- docs/openapi/roundtrip.md | 15 +- .../Scaffold/Cli/OpenApiRoundtripRunner.php | 3 +- .../Scaffold/Emitter/OpenApiEmitter.php | 81 +++++ src/Altair/Scaffold/Spec/Ast/WebhookSpec.php | 28 +- .../Scaffold/Spec/Emitter/OperationMapper.php | 67 ++++ src/Altair/Scaffold/Spec/Parser.php | 16 +- .../Cli/OpenApiWebhookRoundtripTest.php | 286 ++++++++++++++++++ tests/Scaffold/Support/SpecFixture.php | 41 +++ 11 files changed, 572 insertions(+), 32 deletions(-) create mode 100644 tests/Scaffold/Cli/OpenApiWebhookRoundtripTest.php diff --git a/.agent/packages/scaffold.md b/.agent/packages/scaffold.md index 96cd33a3..a173600d 100644 --- a/.agent/packages/scaffold.md +++ b/.agent/packages/scaffold.md @@ -101,6 +101,7 @@ - `tests/Scaffold/Cli/OpenApiImportRunnerTest.php` - `tests/Scaffold/Cli/OpenApiImportScaffoldTest.php` - `tests/Scaffold/Cli/OpenApiRoundtripRunnerTest.php` +- `tests/Scaffold/Cli/OpenApiWebhookRoundtripTest.php` - `tests/Scaffold/Cli/PersistenceInferrerTest.php` - `tests/Scaffold/Cli/ScaffoldCommandIntegrationTest.php` - `tests/Scaffold/Determinism/EmitOpenApiDeterminismTest.php` diff --git a/docs/openapi/extensions.md b/docs/openapi/extensions.md index 87867368..50bdf924 100644 --- a/docs/openapi/extensions.md +++ b/docs/openapi/extensions.md @@ -36,7 +36,7 @@ All keys live at the **operation** level (under | `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` | 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-webhook` | Yes — `spec.webhook` (direction + signing always; other fields when non-default) | [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 @@ -151,16 +151,21 @@ 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-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. +`x-altair-webhook` now round-trips end to end (see +[webhooks.md](./../packages/webhooks.md)). `direction` and `signing` +always travel; every other field (`secret_name`, custom header names, +`dedupe_ttl` / `timestamp_window`, the outbound `retry` block, +`dead_letter`) is written only when it differs from its default. The +importer re-applies the same defaults and the re-emit drops them again, +which is what keeps the block byte-stable through the round-trip gate. +The shared secret itself never appears in OpenAPI — only `secret_name`, +the resolver lookup key, carries through. + ## See also - [docs/openapi/import.md](./import.md) — the importer that consumes these keys diff --git a/docs/openapi/extensions/x-altair-webhook.schema.json b/docs/openapi/extensions/x-altair-webhook.schema.json index 14f6b747..e2e5ba94 100644 --- a/docs/openapi/extensions/x-altair-webhook.schema.json +++ b/docs/openapi/extensions/x-altair-webhook.schema.json @@ -2,7 +2,7 @@ "$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.", + "description": "Marks an operation as an inbound (in) or outbound (out) webhook endpoint and carries the verification / dispatch policy the framework wires at runtime. Inbound operations verify signature + timestamp window + dedupe; outbound operations sign and dispatch with retry + dead-letter. direction and signing always round-trip; every other field round-trips only when it differs from its default, so a key present here is always a non-default value.", "type": "object", "additionalProperties": false, "required": ["direction", "signing"], @@ -16,14 +16,57 @@ "description": "Signing scheme expected on inbound requests / applied to outbound dispatches.", "enum": ["hmac-sha256", "hmac-sha512", "ed25519"] }, + "secret_name": { + "type": "string", + "description": "Lookup key the SecretResolverInterface uses to fetch the shared secret. The secret value itself never appears in OpenAPI — only this key." + }, "header": { "type": "string", - "description": "Header name carrying the signature (inbound). Defaults to X-Signature." + "description": "Header name carrying the signature. Defaults to X-Signature; only emitted when overridden." + }, + "timestamp_header": { + "type": "string", + "description": "Header name carrying the signed timestamp (inbound replay protection). Defaults to X-Timestamp; only emitted when overridden." + }, + "event_id_header": { + "type": "string", + "description": "Header name carrying the event id used for dedupe. Defaults to X-Event-Id; only emitted when overridden." }, "dedupe_ttl": { "type": "string", "pattern": "^[0-9]+(ms|s|m|h|d)$", - "description": "How long to remember the request ID for replay protection." + "description": "How long to remember the event id for replay protection (inbound). Defaults to 1h; only emitted when overridden." + }, + "timestamp_window": { + "type": "string", + "pattern": "^[0-9]+(ms|s|m|h|d)$", + "description": "Maximum age of a signed timestamp before the request is rejected (inbound). Defaults to 5m; only emitted when overridden." + }, + "retry": { + "type": "object", + "additionalProperties": false, + "description": "Outbound retry policy. Only emitted when at least one sub-field differs from its default.", + "properties": { + "max_attempts": { + "type": "integer", + "minimum": 1, + "description": "Attempts before dead-lettering. Defaults to 5." + }, + "backoff": { + "type": "string", + "enum": ["exponential", "linear"], + "description": "Backoff curve between attempts. Defaults to exponential." + }, + "base_delay": { + "type": "string", + "pattern": "^[0-9]+(ms|s|m|h|d)$", + "description": "Delay before the first retry. Defaults to 30s." + } + } + }, + "dead_letter": { + "type": "string", + "description": "Transport name a delivery is routed to after max_attempts is exhausted (outbound)." } } } diff --git a/docs/openapi/roundtrip.md b/docs/openapi/roundtrip.md index 7c2bd4b0..ae9b588f 100644 --- a/docs/openapi/roundtrip.md +++ b/docs/openapi/roundtrip.md @@ -55,9 +55,9 @@ 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`** / - **`x-altair-idempotency`** — full deep equality of any block the - source carried. (See [extensions.md](./extensions.md) for the keys - themselves.) + **`x-altair-idempotency`** / **`x-altair-webhook`** — 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). @@ -169,10 +169,11 @@ 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` 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` is a reserved key — it rides 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-webhook` is now fully compared — + see the list above.) - 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 diff --git a/src/Altair/Scaffold/Cli/OpenApiRoundtripRunner.php b/src/Altair/Scaffold/Cli/OpenApiRoundtripRunner.php index 5b4e064b..ec7b7401 100644 --- a/src/Altair/Scaffold/Cli/OpenApiRoundtripRunner.php +++ b/src/Altair/Scaffold/Cli/OpenApiRoundtripRunner.php @@ -173,6 +173,7 @@ private function projectFromDocument(array $document): array 'x-altair-persistence' => $operation['x-altair-persistence'] ?? null, 'x-altair-queue' => $operation['x-altair-queue'] ?? null, 'x-altair-idempotency' => $operation['x-altair-idempotency'] ?? null, + 'x-altair-webhook' => $operation['x-altair-webhook'] ?? null, 'response_statuses_with_schema' => $this->responseStatusesWithSchema($operation), ]; } @@ -253,7 +254,7 @@ private function compare(array $expected, array $actual): array ); } - foreach (['x-altair-domain', 'x-altair-persistence', 'x-altair-queue', 'x-altair-idempotency'] as $extension) { + foreach (['x-altair-domain', 'x-altair-persistence', 'x-altair-queue', 'x-altair-idempotency', 'x-altair-webhook'] 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 diff --git a/src/Altair/Scaffold/Emitter/OpenApiEmitter.php b/src/Altair/Scaffold/Emitter/OpenApiEmitter.php index 886f4411..505536d4 100644 --- a/src/Altair/Scaffold/Emitter/OpenApiEmitter.php +++ b/src/Altair/Scaffold/Emitter/OpenApiEmitter.php @@ -17,6 +17,7 @@ use Altair\Scaffold\Spec\Ast\PersistenceSpec; use Altair\Scaffold\Spec\Ast\QueueDispatchSpec; use Altair\Scaffold\Spec\Ast\Spec; +use Altair\Scaffold\Spec\Ast\WebhookSpec; use Symfony\Component\Yaml\Yaml; /** @@ -182,9 +183,89 @@ private function renderAltairExtensions(Spec $spec): array ]; } + if ($spec->webhook instanceof WebhookSpec) { + $extensions['x-altair-webhook'] = $this->renderWebhook($spec->webhook); + } + return $extensions; } + /** + * `direction` + `signing` always travel; every other field is omitted + * when it equals its WebhookSpec default. That keeps the extension + * minimal and — because the importer re-applies the same defaults and + * the re-emit drops them again — byte-stable across the round-trip + * gate. Insertion order is fixed: the gate compares extension blocks + * with strict `===`, so the order here must match the order a fixture + * lists them. + * + * @return array + */ + private function renderWebhook(WebhookSpec $webhook): array + { + $rendered = [ + 'direction' => $webhook->direction, + 'signing' => $webhook->signing, + ]; + + if ($webhook->secretName !== null) { + $rendered['secret_name'] = $webhook->secretName; + } + + if ($webhook->signatureHeader !== WebhookSpec::DEFAULT_SIGNATURE_HEADER) { + $rendered['header'] = $webhook->signatureHeader; + } + + if ($webhook->timestampHeader !== WebhookSpec::DEFAULT_TIMESTAMP_HEADER) { + $rendered['timestamp_header'] = $webhook->timestampHeader; + } + + if ($webhook->eventIdHeader !== WebhookSpec::DEFAULT_EVENT_ID_HEADER) { + $rendered['event_id_header'] = $webhook->eventIdHeader; + } + + if ($webhook->dedupeTtl !== WebhookSpec::DEFAULT_DEDUPE_TTL) { + $rendered['dedupe_ttl'] = $webhook->dedupeTtl; + } + + if ($webhook->timestampWindow !== WebhookSpec::DEFAULT_TIMESTAMP_WINDOW) { + $rendered['timestamp_window'] = $webhook->timestampWindow; + } + + $retry = $this->renderWebhookRetry($webhook); + if ($retry !== []) { + $rendered['retry'] = $retry; + } + + if ($webhook->deadLetterTransport !== null) { + $rendered['dead_letter'] = $webhook->deadLetterTransport; + } + + return $rendered; + } + + /** + * @return array + */ + private function renderWebhookRetry(WebhookSpec $webhook): array + { + $retry = []; + + if ($webhook->retryMaxAttempts !== WebhookSpec::DEFAULT_RETRY_MAX_ATTEMPTS) { + $retry['max_attempts'] = $webhook->retryMaxAttempts; + } + + if ($webhook->retryBackoff !== WebhookSpec::BACKOFF_EXPONENTIAL) { + $retry['backoff'] = $webhook->retryBackoff; + } + + if ($webhook->retryBaseDelay !== WebhookSpec::DEFAULT_RETRY_BASE_DELAY) { + $retry['base_delay'] = $webhook->retryBaseDelay; + } + + return $retry; + } + /** * @return array */ diff --git a/src/Altair/Scaffold/Spec/Ast/WebhookSpec.php b/src/Altair/Scaffold/Spec/Ast/WebhookSpec.php index e7cc7a4c..9502f6ea 100644 --- a/src/Altair/Scaffold/Spec/Ast/WebhookSpec.php +++ b/src/Altair/Scaffold/Spec/Ast/WebhookSpec.php @@ -28,18 +28,32 @@ public const string BACKOFF_LINEAR = 'linear'; + public const string DEFAULT_SIGNATURE_HEADER = 'X-Signature'; + + public const string DEFAULT_TIMESTAMP_HEADER = 'X-Timestamp'; + + public const string DEFAULT_EVENT_ID_HEADER = 'X-Event-Id'; + + public const string DEFAULT_DEDUPE_TTL = '1h'; + + public const string DEFAULT_TIMESTAMP_WINDOW = '5m'; + + public const int DEFAULT_RETRY_MAX_ATTEMPTS = 5; + + public const string DEFAULT_RETRY_BASE_DELAY = '30s'; + public function __construct( public string $direction, public string $signing, public ?string $secretName = null, - public string $signatureHeader = 'X-Signature', - public string $timestampHeader = 'X-Timestamp', - public string $eventIdHeader = 'X-Event-Id', - public string $dedupeTtl = '1h', - public string $timestampWindow = '5m', - public int $retryMaxAttempts = 5, + public string $signatureHeader = self::DEFAULT_SIGNATURE_HEADER, + public string $timestampHeader = self::DEFAULT_TIMESTAMP_HEADER, + public string $eventIdHeader = self::DEFAULT_EVENT_ID_HEADER, + public string $dedupeTtl = self::DEFAULT_DEDUPE_TTL, + public string $timestampWindow = self::DEFAULT_TIMESTAMP_WINDOW, + public int $retryMaxAttempts = self::DEFAULT_RETRY_MAX_ATTEMPTS, public string $retryBackoff = self::BACKOFF_EXPONENTIAL, - public string $retryBaseDelay = '30s', + public string $retryBaseDelay = self::DEFAULT_RETRY_BASE_DELAY, public ?string $deadLetterTransport = null, ) {} diff --git a/src/Altair/Scaffold/Spec/Emitter/OperationMapper.php b/src/Altair/Scaffold/Spec/Emitter/OperationMapper.php index 51ad6a4f..bfc6d439 100644 --- a/src/Altair/Scaffold/Spec/Emitter/OperationMapper.php +++ b/src/Altair/Scaffold/Spec/Emitter/OperationMapper.php @@ -62,9 +62,76 @@ public function map(OpenApiDocument $document, OperationModel $operation): array $spec['idempotency'] = $idempotency; } + $webhook = $this->webhookFromExtension($operation); + if ($webhook !== null) { + $spec['webhook'] = $webhook; + } + return $spec; } + /** + * `x-altair-webhook` requires `direction` + `signing`; the remaining + * keys are optional and copied through verbatim when present. Absent + * keys stay absent so the spec Parser re-applies the WebhookSpec + * defaults, which is what keeps the round-trip byte-stable: the forward + * emitter only writes a field when it differs from its default, so a + * key reaching the importer is always a meaningful (non-default) value. + * + * @return ?array + */ + private function webhookFromExtension(OperationModel $operation): ?array + { + $extension = $operation->extensions['x-altair-webhook'] ?? null; + if (!\is_array($extension) + || !isset($extension['direction'], $extension['signing']) + || !\is_string($extension['direction']) || $extension['direction'] === '' + || !\is_string($extension['signing']) || $extension['signing'] === '') { + return null; + } + + $webhook = [ + 'direction' => $extension['direction'], + 'signing' => $extension['signing'], + ]; + + foreach (['secret_name', 'header', 'timestamp_header', 'event_id_header', 'dedupe_ttl', 'timestamp_window', 'dead_letter'] as $key) { + if (isset($extension[$key]) && \is_string($extension[$key]) && $extension[$key] !== '') { + $webhook[$key] = $extension[$key]; + } + } + + $retry = $this->webhookRetryFromExtension($extension['retry'] ?? null); + if ($retry !== []) { + $webhook['retry'] = $retry; + } + + return $webhook; + } + + /** + * @return array + */ + private function webhookRetryFromExtension(mixed $retry): array + { + if (!\is_array($retry)) { + return []; + } + + $result = []; + if (isset($retry['max_attempts']) && \is_int($retry['max_attempts'])) { + $result['max_attempts'] = $retry['max_attempts']; + } + + foreach (['backoff', 'base_delay'] as $key) { + if (isset($retry[$key]) && \is_string($retry[$key]) && $retry[$key] !== '') { + $result[$key] = $retry[$key]; + } + } + + return $result; + } + /** * `x-altair-idempotency` carries `ttl` (required) and `scope` * (defaults to `tenant`). `mode` is not part of the wire contract diff --git a/src/Altair/Scaffold/Spec/Parser.php b/src/Altair/Scaffold/Spec/Parser.php index 4f9eb4c0..0f8f1f5f 100644 --- a/src/Altair/Scaffold/Spec/Parser.php +++ b/src/Altair/Scaffold/Spec/Parser.php @@ -112,7 +112,7 @@ private function parseWebhook(array $data): WebhookSpec throw new SpecParseException("'webhook.retry' must be a map."); } - $maxAttempts = $retry['max_attempts'] ?? 5; + $maxAttempts = $retry['max_attempts'] ?? WebhookSpec::DEFAULT_RETRY_MAX_ATTEMPTS; if (!\is_int($maxAttempts)) { throw new SpecParseException("'webhook.retry.max_attempts' must be an integer."); } @@ -122,14 +122,14 @@ private function parseWebhook(array $data): WebhookSpec direction: $direction, signing: $signing, secretName: $this->webhookOptionalString($data, 'secret_name'), - signatureHeader: $this->webhookString($data, 'header', 'X-Signature'), - timestampHeader: $this->webhookString($data, 'timestamp_header', 'X-Timestamp'), - eventIdHeader: $this->webhookString($data, 'event_id_header', 'X-Event-Id'), - dedupeTtl: $this->webhookString($data, 'dedupe_ttl', '1h'), - timestampWindow: $this->webhookString($data, 'timestamp_window', '5m'), + signatureHeader: $this->webhookString($data, 'header', WebhookSpec::DEFAULT_SIGNATURE_HEADER), + timestampHeader: $this->webhookString($data, 'timestamp_header', WebhookSpec::DEFAULT_TIMESTAMP_HEADER), + eventIdHeader: $this->webhookString($data, 'event_id_header', WebhookSpec::DEFAULT_EVENT_ID_HEADER), + dedupeTtl: $this->webhookString($data, 'dedupe_ttl', WebhookSpec::DEFAULT_DEDUPE_TTL), + timestampWindow: $this->webhookString($data, 'timestamp_window', WebhookSpec::DEFAULT_TIMESTAMP_WINDOW), retryMaxAttempts: $maxAttempts, - retryBackoff: $this->webhookString($retry, 'backoff', 'exponential'), - retryBaseDelay: $this->webhookString($retry, 'base_delay', '30s'), + retryBackoff: $this->webhookString($retry, 'backoff', WebhookSpec::BACKOFF_EXPONENTIAL), + retryBaseDelay: $this->webhookString($retry, 'base_delay', WebhookSpec::DEFAULT_RETRY_BASE_DELAY), deadLetterTransport: $this->webhookOptionalString($data, 'dead_letter'), ); } diff --git a/tests/Scaffold/Cli/OpenApiWebhookRoundtripTest.php b/tests/Scaffold/Cli/OpenApiWebhookRoundtripTest.php new file mode 100644 index 00000000..dbf3d19b --- /dev/null +++ b/tests/Scaffold/Cli/OpenApiWebhookRoundtripTest.php @@ -0,0 +1,286 @@ +sandbox = sys_get_temp_dir() . '/altair-webhook-rt-' . bin2hex(random_bytes(4)); + mkdir($this->sandbox, 0o755, true); + } + + protected function tearDown(): void + { + $this->removeRecursively($this->sandbox); + } + + public function testEmitterWritesInboundWebhookExtension(): void + { + $file = (new OpenApiEmitter())->emit(SpecFixture::createUserWithInboundWebhook()); + $doc = Yaml::parse($file->contents); + + $webhook = $doc['paths']['/users']['post']['x-altair-webhook']; + self::assertSame('in', $webhook['direction']); + self::assertSame('hmac-sha256', $webhook['signing']); + self::assertSame('stripe', $webhook['secret_name']); + self::assertSame('Stripe-Signature', $webhook['header']); + self::assertSame('24h', $webhook['dedupe_ttl']); + // Defaults are omitted to keep the extension minimal + round-trip stable. + self::assertArrayNotHasKey('timestamp_header', $webhook); + self::assertArrayNotHasKey('timestamp_window', $webhook); + self::assertArrayNotHasKey('retry', $webhook); + self::assertArrayNotHasKey('dead_letter', $webhook); + } + + public function testEmitterWritesOutboundWebhookExtension(): void + { + $file = (new OpenApiEmitter())->emit(SpecFixture::createUserWithOutboundWebhook()); + $doc = Yaml::parse($file->contents); + + $webhook = $doc['paths']['/users']['post']['x-altair-webhook']; + self::assertSame('out', $webhook['direction']); + self::assertSame('ed25519', $webhook['signing']); + self::assertSame('webhook.deadletter', $webhook['dead_letter']); + self::assertSame(['max_attempts' => 8, 'backoff' => 'linear'], $webhook['retry']); + // base_delay matched its default, so it does not appear in the retry block. + self::assertArrayNotHasKey('base_delay', $webhook['retry']); + } + + public function testEmitterOmitsEveryDefaultForMinimalWebhook(): void + { + $spec = (new SpecParser())->parseString(<<<'YAML' + endpoint: { method: POST, path: /hooks, summary: Receive, tags: [hooks] } + domain: { class: App\Hook\Receive } + webhook: { direction: in, signing: hmac-sha256 } + YAML); + + $file = (new OpenApiEmitter())->emit($spec); + $webhook = Yaml::parse($file->contents)['paths']['/hooks']['post']['x-altair-webhook']; + + self::assertSame(['direction' => 'in', 'signing' => 'hmac-sha256'], $webhook); + } + + public function testImporterReadsWebhookExtensionBackIntoSpec(): void + { + $documentPath = $this->writeDocument(<<<'YAML' + openapi: 3.1.0 + info: { title: X, version: 1.0 } + paths: + /webhooks/stripe: + post: + operationId: receiveStripe + x-altair-webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + header: Stripe-Signature + dedupe_ttl: 24h + responses: + '200': + description: OK + content: + application/json: + schema: { type: object, properties: { ok: { type: boolean } } } + 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->onlyGeneratedSpec()); + self::assertArrayHasKey('webhook', $spec); + self::assertSame('in', $spec['webhook']['direction']); + self::assertSame('hmac-sha256', $spec['webhook']['signing']); + self::assertSame('stripe', $spec['webhook']['secret_name']); + self::assertSame('Stripe-Signature', $spec['webhook']['header']); + self::assertSame('24h', $spec['webhook']['dedupe_ttl']); + } + + public function testRoundtripPreservesInboundWebhook(): void + { + // NB: extension key order MUST match OpenApiEmitter::renderWebhook's + // insertion order — the gate compares blocks with strict `===`. + $documentPath = $this->writeDocument(<<<'YAML' + openapi: 3.1.0 + info: { title: X, version: 1.0 } + paths: + /webhooks/stripe: + post: + operationId: receiveStripe + x-altair-webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + header: Stripe-Signature + dedupe_ttl: 24h + responses: + '200': + description: OK + content: + application/json: + schema: { type: object, properties: { ok: { type: boolean } } } + YAML); + + $receipt = (new OpenApiRoundtripRunner())->run(new OpenApiRoundtripOptions($documentPath)); + + self::assertTrue($receipt->clean, 'round-trip should preserve x-altair-webhook; diff: ' . $receipt->toJson()); + } + + public function testRoundtripPreservesOutboundWebhookWithRetry(): void + { + $documentPath = $this->writeDocument(<<<'YAML' + openapi: 3.1.0 + info: { title: X, version: 1.0 } + paths: + /posts: + post: + operationId: createPost + x-altair-webhook: + direction: out + signing: ed25519 + retry: + max_attempts: 8 + backoff: linear + dead_letter: webhook.deadletter + responses: + '201': + description: Created + content: + application/json: + schema: { type: object, properties: { id: { type: string } } } + YAML); + + $receipt = (new OpenApiRoundtripRunner())->run(new OpenApiRoundtripOptions($documentPath)); + + self::assertTrue($receipt->clean, 'round-trip should preserve outbound x-altair-webhook; diff: ' . $receipt->toJson()); + } + + public function testRoundtripGateFlagsExtensionDriftWhenWebhookDropped(): void + { + $documentPath = $this->writeDocument(<<<'YAML' + openapi: 3.1.0 + info: { title: X, version: 1.0 } + paths: + /webhooks/stripe: + post: + operationId: receiveStripe + x-altair-webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + responses: + '200': + description: OK + content: + application/json: + schema: { type: object, properties: { ok: { type: boolean } } } + YAML); + + // A deliberately broken emitter that forgets to write the extension: + // the gate must catch the regression as extension_drift. + $brokenEmitter = new class extends OpenApiEmitter { + public function emit(Spec $spec): EmittedFile + { + $file = parent::emit($spec); + /** @var array $doc */ + $doc = Yaml::parse($file->contents); + $paths = $doc['paths']; + foreach ($paths as $path => $methods) { + foreach ($methods as $method => $operation) { + unset($operation['x-altair-webhook']); + $paths[$path][$method] = $operation; + } + } + + $doc['paths'] = $paths; + + return new EmittedFile( + relativePath: $file->relativePath, + contents: Yaml::dump($doc, 8, 2, Yaml::DUMP_OBJECT_AS_MAP), + kind: $file->kind, + ); + } + }; + + $receipt = (new OpenApiRoundtripRunner(openApiEmitter: $brokenEmitter)) + ->run(new OpenApiRoundtripOptions($documentPath)); + + self::assertFalse($receipt->clean, 'dropping x-altair-webhook must fail the gate'); + + $drift = array_filter( + $receipt->differences, + static fn (RoundtripDifference $d): bool => $d->kind === RoundtripDifference::KIND_EXTENSION_DRIFT + && str_contains($d->pointer, 'x-altair-webhook'), + ); + self::assertNotEmpty($drift, 'expected an extension_drift difference on x-altair-webhook; got: ' . $receipt->toJson()); + } + + /** + * The importer derives the spec path from the operation path; rather than + * hard-code the deriver's resource-dir rule, find the single emitted spec. + */ + private function onlyGeneratedSpec(): string + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->sandbox . '/api', \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $node) { + if ($node->isFile() && str_ends_with((string) $node->getFilename(), '.yaml')) { + return $node->getPathname(); + } + } + + self::fail('no generated spec file found under api/'); + } + + 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); + } +} diff --git a/tests/Scaffold/Support/SpecFixture.php b/tests/Scaffold/Support/SpecFixture.php index 063e22e8..1a8d6d63 100644 --- a/tests/Scaffold/Support/SpecFixture.php +++ b/tests/Scaffold/Support/SpecFixture.php @@ -14,6 +14,7 @@ use Altair\Scaffold\Spec\Ast\PersistenceSpec; use Altair\Scaffold\Spec\Ast\QueueDispatchSpec; use Altair\Scaffold\Spec\Ast\Spec; +use Altair\Scaffold\Spec\Ast\WebhookSpec; final class SpecFixture { @@ -79,6 +80,46 @@ public static function createUserWithIdempotency(): Spec ); } + public static function createUserWithInboundWebhook(): Spec + { + $base = self::createUser(); + + return new Spec( + endpoint: $base->endpoint, + inputs: $base->inputs, + outputs: $base->outputs, + domain: $base->domain, + sourcePath: $base->sourcePath, + webhook: new WebhookSpec( + direction: WebhookSpec::DIRECTION_IN, + signing: 'hmac-sha256', + secretName: 'stripe', + signatureHeader: 'Stripe-Signature', + dedupeTtl: '24h', + ), + ); + } + + public static function createUserWithOutboundWebhook(): Spec + { + $base = self::createUser(); + + return new Spec( + endpoint: $base->endpoint, + inputs: $base->inputs, + outputs: $base->outputs, + domain: $base->domain, + sourcePath: $base->sourcePath, + webhook: new WebhookSpec( + direction: WebhookSpec::DIRECTION_OUT, + signing: 'ed25519', + retryMaxAttempts: 8, + retryBackoff: WebhookSpec::BACKOFF_LINEAR, + deadLetterTransport: 'webhook.deadletter', + ), + ); + } + public static function createUserWithPersistence(): Spec { $base = self::createUser();