diff --git a/packages/admin/src/Livewire/Pages/Settings/Webhooks.php b/packages/admin/src/Livewire/Pages/Settings/Webhooks.php index c7b3422e3..6fd0f8993 100644 --- a/packages/admin/src/Livewire/Pages/Settings/Webhooks.php +++ b/packages/admin/src/Livewire/Pages/Settings/Webhooks.php @@ -33,6 +33,7 @@ use Shopper\Core\Models\Traits\HasPublicId; use Shopper\Core\Models\WebhookDelivery; use Shopper\Core\Models\WebhookSubscription; +use Shopper\Core\Webhooks\WebhookRegistry; use Shopper\Core\Webhooks\WebhookUrl; use Shopper\Livewire\Concerns\WithSettingsBreadcrumbs; use Shopper\Sidebar\Breadcrumbs\Breadcrumb; @@ -260,7 +261,7 @@ protected function getWebhookFormSchema(): array }), CheckboxList::make('events') ->label(__('shopper::pages/settings/webhooks.events')) - ->options(collect(config('shopper.webhooks.events', [])) + ->options(collect(resolve(WebhookRegistry::class)->events()) ->values() ->mapWithKeys(fn (string $name): array => [$name => $name]) ->all()) diff --git a/packages/cart/src/CartServiceProvider.php b/packages/cart/src/CartServiceProvider.php index 54c419839..7378ffeb7 100644 --- a/packages/cart/src/CartServiceProvider.php +++ b/packages/cart/src/CartServiceProvider.php @@ -20,6 +20,7 @@ use Shopper\Cart\Pipelines\CartPipelineRunner; use Shopper\Core\Enum\WebhookEventType; use Shopper\Core\Traits\HasRegisterConfigAndMigrationFiles; +use Shopper\Core\Webhooks\Facades\Webhooks; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -47,10 +48,7 @@ public function packageBooted(): void $schedule->command('shopper:prune-carts')->daily(); }); - $this->app['config']->set( - 'shopper.webhooks.events.'.CartCompleted::class, - WebhookEventType::CartCompleted->value, - ); + Webhooks::register(CartCompleted::class, WebhookEventType::CartCompleted->value); } public function packageRegistered(): void diff --git a/packages/core/config/webhooks.php b/packages/core/config/webhooks.php index 6cdee9cf9..e5b7c6904 100644 --- a/packages/core/config/webhooks.php +++ b/packages/core/config/webhooks.php @@ -20,8 +20,9 @@ |-------------------------------------------------------------------------- | | Here you may map internal event classes to the public names webhook - | subscriptions listen to. Packages and addons append their own entries. - | The public name is the wire contract and must stay stable. + | subscriptions listen to. Packages and addons register their own events + | through the Webhooks facade in their service provider. The public name + | is the wire contract and must stay stable. | */ diff --git a/packages/core/src/CoreServiceProvider.php b/packages/core/src/CoreServiceProvider.php index 30231347e..bd632231a 100755 --- a/packages/core/src/CoreServiceProvider.php +++ b/packages/core/src/CoreServiceProvider.php @@ -19,7 +19,6 @@ use Shopper\Core\Contracts\TaxCalculationProvider; use Shopper\Core\Contracts\WebhookPayloadSerializer; use Shopper\Core\Import\ImportManager; -use Shopper\Core\Listeners\DispatchWebhooksListener; use Shopper\Core\Models\Address; use Shopper\Core\Models\Attribute; use Shopper\Core\Models\Category; @@ -50,6 +49,7 @@ use Shopper\Core\Taxes\TaxCalculator; use Shopper\Core\Traits\HasRegisterConfigAndMigrationFiles; use Shopper\Core\Webhooks\DefaultWebhookPayloadSerializer; +use Shopper\Core\Webhooks\WebhookRegistry; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -72,6 +72,7 @@ final class CoreServiceProvider extends PackageServiceProvider ChannelManager::class => ChannelManager::class, ImportManager::class => ImportManager::class, WebhookPayloadSerializer::class => DefaultWebhookPayloadSerializer::class, + WebhookRegistry::class => WebhookRegistry::class, ]; /** @var string[] */ @@ -122,12 +123,7 @@ public function packageRegistered(): void protected function registerWebhookListener(): void { $this->app->booted(function (): void { - /** @var array $events */ - $events = (array) config('shopper.webhooks.events', []); - - if ($events !== []) { - $this->app['events']->listen(array_keys($events), DispatchWebhooksListener::class); - } + $this->app->make(WebhookRegistry::class)->activate(); }); } diff --git a/packages/core/src/Listeners/DispatchWebhooksListener.php b/packages/core/src/Listeners/DispatchWebhooksListener.php index d85a173ba..7db9a3b8f 100644 --- a/packages/core/src/Listeners/DispatchWebhooksListener.php +++ b/packages/core/src/Listeners/DispatchWebhooksListener.php @@ -11,30 +11,13 @@ use Shopper\Core\Models\WebhookDelivery; use Shopper\Core\Models\WebhookEvent; use Shopper\Core\Models\WebhookSubscription; +use Shopper\Core\Webhooks\WebhookRegistry; use Throwable; -/** - * Fans a domain event out to the webhook subscriptions listening to it. - * - * Runs synchronously in the dispatching request. Do not make this listener - * queued: domain events use `SerializesModels`, so a queued listener - * re-fetches the model on wake-up and throws `ModelNotFoundException` for - * every `*Deleted` event. The payload is therefore snapshotted here, and - * the queued `DeliverWebhookJob` only posts the frozen JSON. - * - * Per event: resolves the public name from `shopper.webhooks.events`, - * reads the cached active-subscription list (no query on stores without - * webhooks), creates one `WebhookEvent` row (payload stored once), then one - * pending `WebhookDelivery` plus one queued job per matching subscription. - * - * Every failure path is contained: a failing subscription never aborts the - * remaining ones, and no exception escapes `handle()` — the listener runs - * in an after-commit callback of a business transaction (checkout, payment) - * and must never turn a committed order into a 500. - */ final readonly class DispatchWebhooksListener { public function __construct( + private WebhookRegistry $registry, private WebhookPayloadSerializer $serializer, ) {} @@ -52,9 +35,9 @@ public function handle(object $event): void private function dispatchWebhooks(object $event): void { - $name = config('shopper.webhooks.events.'.$event::class); + $name = $this->registry->nameFor($event::class); - if (! is_string($name)) { + if ($name === null) { return; } @@ -65,7 +48,7 @@ private function dispatchWebhooks(object $event): void return; } - $serialized = $this->serializer->serialize($event); + $serialized = $this->registry->serialize($event) ?? $this->normalize($this->serializer->serialize($event)); $webhookEvent = WebhookEvent::query()->create([ 'name' => $name, @@ -97,4 +80,20 @@ private function dispatchWebhooks(object $event): void } } } + + /** + * A rebound serializer may not honour the contract's declared shape at + * runtime, so the payload is normalised before it is persisted. + * + * @param array $payload + * @return array{resource_type: ?string, resource_id: ?string, data: array} + */ + private function normalize(array $payload): array + { + return [ + 'resource_type' => $payload['resource_type'] ?? null, + 'resource_id' => $payload['resource_id'] ?? null, + 'data' => $payload['data'] ?? [], + ]; + } } diff --git a/packages/core/src/Webhooks/DefaultWebhookPayloadSerializer.php b/packages/core/src/Webhooks/DefaultWebhookPayloadSerializer.php index 1a70033b3..7ef62a75c 100644 --- a/packages/core/src/Webhooks/DefaultWebhookPayloadSerializer.php +++ b/packages/core/src/Webhooks/DefaultWebhookPayloadSerializer.php @@ -5,6 +5,7 @@ namespace Shopper\Core\Webhooks; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Log; use Shopper\Core\Contracts\WebhookPayloadSerializer; use Shopper\Core\Models\Contracts\Order; use Shopper\Core\Models\Contracts\Product; @@ -21,6 +22,10 @@ public function serialize(object $event): array $resource = $this->resourceOf($event); if (! $resource instanceof Model) { + Log::warning('Webhook payload is empty: the event exposes no known resource. Register a serializer for it.', [ + 'event' => $event::class, + ]); + return ['resource_type' => null, 'resource_id' => null, 'data' => []]; } diff --git a/packages/core/src/Webhooks/Facades/Webhooks.php b/packages/core/src/Webhooks/Facades/Webhooks.php new file mode 100644 index 000000000..73081b7c5 --- /dev/null +++ b/packages/core/src/Webhooks/Facades/Webhooks.php @@ -0,0 +1,23 @@ + events() + * @method static ?string nameFor(string $eventClass) + * + * @see WebhookRegistry + */ +final class Webhooks extends Facade +{ + protected static function getFacadeAccessor(): string + { + return WebhookRegistry::class; + } +} diff --git a/packages/core/src/Webhooks/WebhookRegistry.php b/packages/core/src/Webhooks/WebhookRegistry.php new file mode 100644 index 000000000..f3c8b8a18 --- /dev/null +++ b/packages/core/src/Webhooks/WebhookRegistry.php @@ -0,0 +1,212 @@ + */ + private array $events = []; + + /** @var array */ + private array $defaults = []; + + /** @var array> */ + private array $serializers = []; + + /** @var array */ + private array $warned = []; + + private bool $seeded = false; + + private bool $activated = false; + + public function __construct( + private readonly DispatcherContract $dispatcher, + ) {} + + /** + * Config entries are defaults: code may override their public name and + * attach a serializer to them. Only a conflict between two code + * registrations is a programming error and throws. + * + * @param class-string $eventClass + * @param (Closure(object): array{resource_type: ?string, resource_id: ?string, data: array})|class-string|null $serializer + */ + public function register(string $eventClass, string $name, Closure|string|null $serializer = null): self + { + $this->ensureSeeded(); + + $existing = $this->events[$eventClass] ?? null; + + if ($existing !== null && ! isset($this->defaults[$eventClass])) { + if ($existing !== $name) { + throw new LogicException("Webhook event [{$eventClass}] is already registered as [{$existing}]."); + } + + if ($serializer !== null) { + throw new LogicException("Webhook event [{$eventClass}] is already registered and its serializer cannot be replaced."); + } + } + + $owner = array_search($name, $this->events, true); + + if ($owner !== false && $owner !== $eventClass) { + $this->warnOnce("name:{$name}", 'Webhook event ignored: the public name is already used by another event.', [ + 'event' => $eventClass, + 'name' => $name, + 'owner' => $owner, + ]); + + return $this; + } + + if ($serializer !== null) { + $this->serializers[$eventClass] = $serializer; + } + + if ($existing === null && $this->activated) { + $this->listen($eventClass); + } + + unset($this->defaults[$eventClass]); + + $this->events[$eventClass] = $name; + + return $this; + } + + public function activate(): void + { + if ($this->activated) { + return; + } + + $this->seeded = true; + $this->seedConfiguredEvents(); + + foreach (array_keys($this->events) as $eventClass) { + $this->listen($eventClass); + } + + $this->activated = true; + } + + /** + * @return array + */ + public function events(): array + { + $this->ensureSeeded(); + + return $this->events; + } + + public function nameFor(string $eventClass): ?string + { + $this->ensureSeeded(); + + return $this->events[$eventClass] ?? null; + } + + /** + * Null means no serializer is registered for the event, never a broken + * one: a registered serializer returning a malformed shape is normalised + * here so it can never be mistaken for an absent serializer and silently + * fall back to the default payload. + * + * @return ?array{resource_type: ?string, resource_id: ?string, data: array} + */ + public function serialize(object $event): ?array + { + $serializer = $this->serializers[$event::class] ?? null; + + if ($serializer === null) { + return null; + } + + $payload = $serializer instanceof Closure + ? $serializer($event) + : resolve($serializer)->serialize($event); + + return [ + 'resource_type' => $payload['resource_type'] ?? null, + 'resource_id' => $payload['resource_id'] ?? null, + 'data' => $payload['data'] ?? [], + ]; + } + + private function ensureSeeded(): void + { + if ($this->seeded) { + return; + } + + $this->seeded = true; + $this->seedConfiguredEvents(); + } + + private function seedConfiguredEvents(): void + { + foreach ((array) config('shopper.webhooks.events', []) as $eventClass => $name) { + if (! is_string($eventClass) || ! is_string($name)) { + $this->warnOnce( + 'malformed:'.(is_string($eventClass) ? $eventClass : (string) json_encode($eventClass)), + 'Webhook config entry ignored: the event class and public name must both be strings.', + ['event' => $eventClass], + ); + + continue; + } + + if (isset($this->events[$eventClass])) { + continue; + } + + if (in_array($name, $this->events, true)) { + $this->warnOnce("name:{$name}", 'Webhook config entry ignored: the public name is already used by another event.', [ + 'event' => $eventClass, + 'name' => $name, + ]); + + continue; + } + + $this->defaults[$eventClass] = true; + $this->events[$eventClass] = $name; + } + } + + private function listen(string $eventClass): void + { + if ($this->dispatcher instanceof Dispatcher + && in_array(DispatchWebhooksListener::class, $this->dispatcher->getRawListeners()[$eventClass] ?? [], true)) { + return; + } + + $this->dispatcher->listen($eventClass, DispatchWebhooksListener::class); + } + + /** + * @param array $context + */ + private function warnOnce(string $key, string $message, array $context): void + { + if (isset($this->warned[$key])) { + return; + } + + $this->warned[$key] = true; + + Log::warning($message, $context); + } +} diff --git a/tests/Core/Workflows/Webhooks/Stubs/AddonWebhookServiceProvider.php b/tests/Core/Workflows/Webhooks/Stubs/AddonWebhookServiceProvider.php new file mode 100644 index 000000000..9e10eaec7 --- /dev/null +++ b/tests/Core/Workflows/Webhooks/Stubs/AddonWebhookServiceProvider.php @@ -0,0 +1,42 @@ + [ + 'resource_type' => 'subscription', + 'resource_id' => 'sub_'.$event->plan, + 'data' => ['plan' => $event->plan], + ], + ); + + Webhooks::register( + InvoiceGenerated::class, + 'invoice.generated', + InvoicePayloadSerializer::class, + ); + + Webhooks::register( + MalformedPayload::class, + 'malformed.payload', + fn (MalformedPayload $event): array => ['data' => ['reason' => $event->reason]], + ); + + Webhooks::register( + NullPayload::class, + 'null.payload', + fn (NullPayload $event) => null, + ); + } +} diff --git a/tests/Core/Workflows/Webhooks/Stubs/InvoiceGenerated.php b/tests/Core/Workflows/Webhooks/Stubs/InvoiceGenerated.php new file mode 100644 index 000000000..e864d29c9 --- /dev/null +++ b/tests/Core/Workflows/Webhooks/Stubs/InvoiceGenerated.php @@ -0,0 +1,12 @@ + 'invoice', + 'resource_id' => $event->number, + 'data' => ['number' => $event->number], + ]; + } +} diff --git a/tests/Core/Workflows/Webhooks/Stubs/MalformedPayload.php b/tests/Core/Workflows/Webhooks/Stubs/MalformedPayload.php new file mode 100644 index 000000000..c44ac5289 --- /dev/null +++ b/tests/Core/Workflows/Webhooks/Stubs/MalformedPayload.php @@ -0,0 +1,12 @@ +toHaveKey(Shopper\Core\Events\Orders\OrderPaid::class, 'order.paid') + ->toHaveKey(SubscriptionRenewed::class, 'subscription.renewed') + ->toHaveKey(InvoiceGenerated::class, 'invoice.generated'); + }); + + it('dispatches a registered event through its closure serializer', function (): void { + WebhookSubscription::factory()->create(['events' => ['subscription.renewed']]); + + event(new SubscriptionRenewed('premium')); + + $webhookEvent = WebhookEvent::query()->where('name', 'subscription.renewed')->sole(); + + expect($webhookEvent->resource_type)->toBe('subscription') + ->and($webhookEvent->resource_id)->toBe('sub_premium') + ->and($webhookEvent->payload)->toBe(['plan' => 'premium']); + + Bus::assertDispatched(DeliverWebhookJob::class); + }); + + it('dispatches a registered event through its class serializer', function (): void { + WebhookSubscription::factory()->create(['events' => ['invoice.generated']]); + + event(new InvoiceGenerated('INV-001')); + + $webhookEvent = WebhookEvent::query()->where('name', 'invoice.generated')->sole(); + + expect($webhookEvent->resource_type)->toBe('invoice') + ->and($webhookEvent->payload)->toBe(['number' => 'INV-001']); + }); + + it('falls back to the bound serializer for events registered without one', function (): void { + WebhookSubscription::factory()->create(['events' => ['order.paid']]); + + $order = Shopper\Core\Models\Order::factory()->create(['price_amount' => 4200]); + + event(new Shopper\Core\Events\Orders\OrderPaid($order)); + + $webhookEvent = WebhookEvent::query()->where('name', 'order.paid')->sole(); + + expect($webhookEvent->payload['price_amount'])->toBe(4200); + }); + + it('dispatches a webhook for an event registered after the application booted', function (): void { + $event = new class + { + public string $reference = 'ref-1'; + }; + + Webhooks::register($event::class, 'post.boot.event', fn (object $e): array => [ + 'resource_type' => 'reference', + 'resource_id' => $e->reference, + 'data' => ['reference' => $e->reference], + ]); + + WebhookSubscription::factory()->create(['events' => ['post.boot.event']]); + + event($event); + + $webhookEvent = WebhookEvent::query()->where('name', 'post.boot.event')->sole(); + + expect($webhookEvent->resource_id)->toBe('ref-1') + ->and($webhookEvent->payload)->toBe(['reference' => 'ref-1']); + }); + + it('refuses to rename an event another registration already owns', function (): void { + expect(fn () => Webhooks::register(SubscriptionRenewed::class, 'subscription.renamed')) + ->toThrow(LogicException::class, 'already registered as [subscription.renewed]'); + }); + + it('refuses to replace the serializer of an event another registration owns', function (): void { + expect(fn () => Webhooks::register(SubscriptionRenewed::class, 'subscription.renewed', fn (object $e): array => [ + 'resource_type' => null, + 'resource_id' => null, + 'data' => [], + ]))->toThrow(LogicException::class, 'serializer cannot be replaced'); + }); + + it('lets code override a config default and attach a serializer to a core event', function (): void { + Webhooks::register(Shopper\Core\Events\Orders\OrderPaid::class, 'order.paid', fn (object $e): array => [ + 'resource_type' => 'order', + 'resource_id' => 'custom', + 'data' => ['shaped_by' => 'addon'], + ]); + + WebhookSubscription::factory()->create(['events' => ['order.paid']]); + + event(new Shopper\Core\Events\Orders\OrderPaid(Shopper\Core\Models\Order::factory()->create())); + + $webhookEvent = WebhookEvent::query()->where('name', 'order.paid')->sole(); + + expect($webhookEvent->payload)->toBe(['shaped_by' => 'addon']) + ->and($webhookEvent->resource_id)->toBe('custom'); + }); + + it('keeps the store booting when two registrations claim the same public name', function (): void { + $event = new class {}; + + Webhooks::register($event::class, 'order.paid'); + + expect(Webhooks::nameFor($event::class))->toBeNull() + ->and(Webhooks::nameFor(Shopper\Core\Events\Orders\OrderPaid::class))->toBe('order.paid'); + }); + + it('accepts re-registering the same event with the same name without doubling deliveries', function (): void { + Webhooks::register(Shopper\Core\Events\Orders\OrderPaid::class, 'order.paid'); + + WebhookSubscription::factory()->create(['events' => ['order.paid']]); + + event(new Shopper\Core\Events\Orders\OrderPaid(Shopper\Core\Models\Order::factory()->create())); + + expect(WebhookEvent::query()->where('name', 'order.paid')->count())->toBe(1); + }); + + it('ignores malformed config entries instead of failing the boot', function (): void { + Log::spy(); + + config()->set('shopper.webhooks.events', [ + Shopper\Core\Events\Orders\OrderPaid::class => Shopper\Core\Enum\WebhookEventType::OrderPaid, + 'events.without.class-string.key', + ]); + + $registry = new Shopper\Core\Webhooks\WebhookRegistry(app('events')); + + expect($registry->events())->toBe([]); + + Log::shouldHaveReceived('warning')->twice(); + }); + + it('still delivers a degraded envelope when a serializer omits the resource keys', function (): void { + WebhookSubscription::factory()->create(['events' => ['malformed.payload']]); + + event(new MalformedPayload('missing keys')); + + $webhookEvent = WebhookEvent::query()->where('name', 'malformed.payload')->sole(); + + expect($webhookEvent->resource_type)->toBeNull() + ->and($webhookEvent->payload)->toBe(['reason' => 'missing keys']); + + Bus::assertDispatched(DeliverWebhookJob::class); + }); + + it('never falls back to the default payload when a registered serializer returns null', function (): void { + WebhookSubscription::factory()->create(['events' => ['null.payload']]); + + event(new NullPayload(Shopper\Core\Models\Order::factory()->create(['price_amount' => 9900]))); + + $webhookEvent = WebhookEvent::query()->where('name', 'null.payload')->sole(); + + expect($webhookEvent->payload)->toBe([]) + ->and($webhookEvent->resource_type)->toBeNull(); + }); + + it('still records the event when a rebound payload serializer omits the resource keys', function (): void { + app()->bind(Shopper\Core\Contracts\WebhookPayloadSerializer::class, fn (): object => new class implements Shopper\Core\Contracts\WebhookPayloadSerializer + { + public function serialize(object $event): array + { + return ['data' => ['rebound' => true]]; + } + }); + + WebhookSubscription::factory()->create(['events' => ['order.paid']]); + + event(new Shopper\Core\Events\Orders\OrderPaid(Shopper\Core\Models\Order::factory()->create())); + + $webhookEvent = WebhookEvent::query()->where('name', 'order.paid')->sole(); + + expect($webhookEvent->payload)->toBe(['rebound' => true]) + ->and($webhookEvent->resource_type)->toBeNull(); + + Bus::assertDispatched(DeliverWebhookJob::class); + }); + + it('warns only once per malformed config entry across repeated seeds', function (): void { + Log::spy(); + + config()->set('shopper.webhooks.events', [ + Shopper\Core\Events\Orders\OrderPaid::class => Shopper\Core\Enum\WebhookEventType::OrderPaid, + ]); + + $registry = new Shopper\Core\Webhooks\WebhookRegistry(app('events')); + + $registry->events(); + $registry->activate(); + + Log::shouldHaveReceived('warning')->once(); + }); +})->group('webhooks'); diff --git a/tests/Core/Workflows/Webhooks/WebhookRegistryTestCase.php b/tests/Core/Workflows/Webhooks/WebhookRegistryTestCase.php new file mode 100644 index 000000000..97f431fb4 --- /dev/null +++ b/tests/Core/Workflows/Webhooks/WebhookRegistryTestCase.php @@ -0,0 +1,19 @@ +