Skip to content

webhook: spec block + scaffolder integration (AST + parser + validator + emitter wiring) #188

@tonydspaniard

Description

@tonydspaniard

Part of #184. Depends on #186 (inbound middleware) and #187 (outbound dispatcher).

Goal

Add a webhook: block to Altair YAML specs, wire the parser / validator to accept it, and update the scaffolder so a spec carrying the block produces an Action that exposes its webhook policy via a static webhook() accessor (mirroring the idempotency() pattern from #174).

The auto-wiring middleware from #186 (ActionAwareWebhookVerifyMiddleware) reads that accessor at request time and configures the verify pipeline. For outbound specs, the scaffolder emits a dispatcher binding the application can use.

Shape

YAML spec block — inbound

webhook:
  direction: in
  signing: hmac-sha256      # required when direction=in
  secret_name: stripe       # required when direction=in; resolves via SecretResolverInterface
  header: X-Signature       # optional; default 'X-Signature'
  timestamp_header: X-Timestamp  # optional; default 'X-Timestamp'
  event_id_header: X-Event-Id    # optional; default 'X-Event-Id'
  dedupe_ttl: 1h            # optional; default '1h'
  timestamp_window: 5m      # optional; default '5m'

YAML spec block — outbound

webhook:
  direction: out
  signing: hmac-sha256
  retry:
    max_attempts: 5         # optional; default 5
    backoff: exponential    # optional; 'exponential' | 'linear'; default 'exponential'
    base_delay: 30s         # optional; default '30s'
  dead_letter: webhook.dlq  # optional; transport name for dead-lettering

When absent: no metadata emitted on the Action, no dispatcher binding emitted. Backward-compatible.

AST node

Altair\Scaffold\Spec\Ast\WebhookSpec:

final readonly class WebhookSpec
{
    public const string DIRECTION_IN = 'in';
    public const string DIRECTION_OUT = 'out';

    public function __construct(
        public string $direction,
        public string $signing,
        public ?string $secretName = null,        // inbound only
        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,         // outbound only
        public string $retryBackoff = 'exponential',
        public string $retryBaseDelay = '30s',
        public ?string $deadLetterTransport = null,
    ) {}
}

Add ?WebhookSpec $webhook = null to Altair\Scaffold\Spec\Ast\Spec.

Parser / Validator

  • Parser reads the block; builds WebhookSpec; attaches to Spec.
  • Validator enforces:
    • direction is in or out
    • signing is one of the known signers (hmac-sha256, hmac-sha512, ed25519)
    • secret_name is non-empty when direction=in
    • dedupe_ttl / timestamp_window / retry.base_delay match ^[0-9]+(ms|s|m|h|d)$ (same pattern as idempotency TTL)
    • retry.max_attempts is positive
    • retry.backoff is exponential or linear

Scaffolder integration

For direction=in: ActionEmitter adds a static webhook() accessor on the generated Action exposing the policy. Host's ActionAwareWebhookVerifyMiddleware reads it.

For direction=out: a new WebhookDispatcherBindingEmitter writes a small PHP file under app/Webhooks/<EndpointName>Dispatcher.php that constructs a WebhookDispatcher configured per the spec, ready for the application to call.

Acceptance criteria

  • WebhookSpec AST node lands; Spec grows ?WebhookSpec $webhook = null with backwards-compatible default
  • Parser reads the block; Validator rejects every malformed combination with actionable messages
  • Action emitter generates a static webhook() accessor when direction=in; output is byte-for-byte identical to today's scaffold when the block is absent
  • Dispatcher binding emitter runs when direction=out; the resulting PHP file constructs a configured WebhookDispatcher
  • Snapshot tests for the generated Action (inbound) and the generated dispatcher binding (outbound)
  • Determinism: same spec \xe2\x86\x92 byte-identical scaffold
  • Round-trip via existing Parser + Validator keeps the new field working

Out of scope

  • The x-altair-webhook round-trip activation \xe2\x80\x94 separate issue (it depends on this one landing first to have something to round-trip)
  • Docs + benchmark variant \xe2\x80\x94 own issue, lands after round-trip

Notes

Naming for the static accessor: webhook() (singular) matches idempotency() from #174. The method returns the full policy array ['direction' => ..., 'signing' => ..., ...]. The middleware filters by direction === 'in'; the dispatcher binding doesn't read the accessor at all (it's emitted as configuration, not introspected at runtime).

The dispatcher-binding emission is the new pattern in this issue. Mirrors the way EntityEmitter / MigrationEmitter work \xe2\x80\x94 it's an additional emitted file rather than a tweak to the Action. Keep it under app/Webhooks/ so the host has a clear namespace for these.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions