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 @@ -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`
Expand Down
17 changes: 11 additions & 6 deletions docs/openapi/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 46 additions & 3 deletions docs/openapi/extensions/x-altair-webhook.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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)."
}
}
}
15 changes: 8 additions & 7 deletions docs/openapi/roundtrip.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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
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 @@ -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),
];
}
Expand Down Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions src/Altair/Scaffold/Emitter/OpenApiEmitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
Expand Down
28 changes: 21 additions & 7 deletions src/Altair/Scaffold/Spec/Ast/WebhookSpec.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}

Expand Down
67 changes: 67 additions & 0 deletions src/Altair/Scaffold/Spec/Emitter/OperationMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>
*/
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<string, mixed>
*/
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
Expand Down
Loading
Loading