From a1fcfaef10df603670a19a3798502e070424a95d Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Thu, 12 Sep 2024 20:33:27 -0400 Subject: [PATCH] [Webhook] Allow request parsers to return multiple `RemoteEvent`'s --- UPGRADE-7.2.md | 9 +++ src/Symfony/Component/Webhook/CHANGELOG.md | 1 + .../Webhook/Client/AbstractRequestParser.php | 4 +- .../Webhook/Client/RequestParserInterface.php | 4 +- .../Webhook/Controller/WebhookController.php | 9 ++- .../Test/AbstractRequestParserTestCase.php | 4 +- .../Controller/WebhookControllerTest.php | 59 ++++++++++++++++++- 7 files changed, 79 insertions(+), 11 deletions(-) diff --git a/UPGRADE-7.2.md b/UPGRADE-7.2.md index 7c0c8064f4381..21e41530e8b17 100644 --- a/UPGRADE-7.2.md +++ b/UPGRADE-7.2.md @@ -84,6 +84,15 @@ TwigBridge * Deprecate passing a tag to the constructor of `FormThemeNode` +Webhook +------- + + * [BC BREAK] `RequestParserInterface::parse()` return type changed from + `?RemoteEvent` to `RemoteEvent|array|null`. Classes already + implementing this interface are unaffected but consumers of this method + will need to be updated to handle the new return type. Projects relying on + the `WebhookController` of the component are not affected by the BC break + Yaml ---- diff --git a/src/Symfony/Component/Webhook/CHANGELOG.md b/src/Symfony/Component/Webhook/CHANGELOG.md index aaa1e62d24ddb..2cfc1d7d36e25 100644 --- a/src/Symfony/Component/Webhook/CHANGELOG.md +++ b/src/Symfony/Component/Webhook/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Make `AbstractRequestParserTestCase` compatible with PHPUnit 10+ * Add `PayloadSerializerInterface` with implementations to decouple the remote event handling from the Serializer component * Add optional `$request` argument to `RequestParserInterface::createSuccessfulResponse()` and `RequestParserInterface::createRejectedResponse()` + * [BC BREAK] Change return type of `RequestParserInterface::parse()` to `RemoteEvent|array|null` (from `?RemoteEvent`) 6.4 --- diff --git a/src/Symfony/Component/Webhook/Client/AbstractRequestParser.php b/src/Symfony/Component/Webhook/Client/AbstractRequestParser.php index e8bf3f3e00317..227efd1e86783 100644 --- a/src/Symfony/Component/Webhook/Client/AbstractRequestParser.php +++ b/src/Symfony/Component/Webhook/Client/AbstractRequestParser.php @@ -22,7 +22,7 @@ */ abstract class AbstractRequestParser implements RequestParserInterface { - public function parse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent + public function parse(Request $request, #[\SensitiveParameter] string $secret): RemoteEvent|array|null { $this->validate($request); @@ -47,7 +47,7 @@ public function createRejectedResponse(string $reason/* , ?Request $request = nu abstract protected function getRequestMatcher(): RequestMatcherInterface; - abstract protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent; + abstract protected function doParse(Request $request, #[\SensitiveParameter] string $secret): RemoteEvent|array|null; protected function validate(Request $request): void { diff --git a/src/Symfony/Component/Webhook/Client/RequestParserInterface.php b/src/Symfony/Component/Webhook/Client/RequestParserInterface.php index cb84f82e595e5..dd6f1632e41c3 100644 --- a/src/Symfony/Component/Webhook/Client/RequestParserInterface.php +++ b/src/Symfony/Component/Webhook/Client/RequestParserInterface.php @@ -24,11 +24,11 @@ interface RequestParserInterface /** * Parses an HTTP Request and converts it into a RemoteEvent. * - * @return ?RemoteEvent Returns null if the webhook must be ignored + * @return RemoteEvent|RemoteEvent[]|null Returns null if the webhook must be ignored * * @throws RejectWebhookException When the payload is rejected (signature issue, parse issue, ...) */ - public function parse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent; + public function parse(Request $request, #[\SensitiveParameter] string $secret): RemoteEvent|array|null; /** * @param Request|null $request The original request that was received by the webhook controller diff --git a/src/Symfony/Component/Webhook/Controller/WebhookController.php b/src/Symfony/Component/Webhook/Controller/WebhookController.php index f46fe2a5f10c8..a5df467fdc162 100644 --- a/src/Symfony/Component/Webhook/Controller/WebhookController.php +++ b/src/Symfony/Component/Webhook/Controller/WebhookController.php @@ -40,12 +40,17 @@ public function handle(string $type, Request $request): Response } /** @var RequestParserInterface $parser */ $parser = $this->parsers[$type]['parser']; + $events = $parser->parse($request, $this->parsers[$type]['secret']); - if (!$event = $parser->parse($request, $this->parsers[$type]['secret'])) { + if (!$events) { return $parser->createRejectedResponse('Unable to parse the webhook payload.', $request); } - $this->bus->dispatch(new ConsumeRemoteEventMessage($type, $event)); + $events = \is_array($events) ? $events : [$events]; + + foreach ($events as $event) { + $this->bus->dispatch(new ConsumeRemoteEventMessage($type, $event)); + } return $parser->createSuccessfulResponse($request); } diff --git a/src/Symfony/Component/Webhook/Test/AbstractRequestParserTestCase.php b/src/Symfony/Component/Webhook/Test/AbstractRequestParserTestCase.php index 3afcd76624e70..eadf2ac72b70c 100644 --- a/src/Symfony/Component/Webhook/Test/AbstractRequestParserTestCase.php +++ b/src/Symfony/Component/Webhook/Test/AbstractRequestParserTestCase.php @@ -26,7 +26,7 @@ abstract class AbstractRequestParserTestCase extends TestCase * @dataProvider getPayloads */ #[DataProvider('getPayloads')] - public function testParse(string $payload, RemoteEvent $expected) + public function testParse(string $payload, RemoteEvent|array $expected) { $request = $this->createRequest($payload); $parser = $this->createRequestParser(); @@ -35,7 +35,7 @@ public function testParse(string $payload, RemoteEvent $expected) } /** - * @return iterable + * @return iterable */ public static function getPayloads(): iterable { diff --git a/src/Symfony/Component/Webhook/Tests/Controller/WebhookControllerTest.php b/src/Symfony/Component/Webhook/Tests/Controller/WebhookControllerTest.php index b97f7092d9e65..1a3d5196e1e5b 100644 --- a/src/Symfony/Component/Webhook/Tests/Controller/WebhookControllerTest.php +++ b/src/Symfony/Component/Webhook/Tests/Controller/WebhookControllerTest.php @@ -32,7 +32,10 @@ public function testNoParserAvailable() $this->assertSame(404, $response->getStatusCode()); } - public function testParserRejectsPayload() + /** + * @dataProvider rejectedParseProvider + */ + public function testParserRejectsPayload($return) { $secret = '1234'; $request = new Request(); @@ -41,7 +44,7 @@ public function testParserRejectsPayload() ->expects($this->once()) ->method('parse') ->with($request, $secret) - ->willReturn(null); + ->willReturn($return); $parser ->expects($this->once()) ->method('createRejectedResponse') @@ -59,7 +62,13 @@ public function testParserRejectsPayload() $this->assertSame('Unable to parse the webhook payload.', $response->getContent()); } - public function testParserAcceptsPayload() + public static function rejectedParseProvider(): iterable + { + yield 'null' => [null]; + yield 'empty array' => [[]]; + } + + public function testParserAcceptsPayloadAndReturnsSingleEvent() { $secret = '1234'; $request = new Request(); @@ -97,4 +106,48 @@ public function dispatch(object $message, array $stamps = []): Envelope $this->assertSame('foo', $bus->message->getType()); $this->assertEquals($event, $bus->message->getEvent()); } + + public function testParserAcceptsPayloadAndReturnsMultipleEvents() + { + $secret = '1234'; + $request = new Request(); + $event1 = new RemoteEvent('name1', 'id1', ['payload1']); + $event2 = new RemoteEvent('name2', 'id2', ['payload2']); + $parser = $this->createMock(RequestParserInterface::class); + $parser + ->expects($this->once()) + ->method('parse') + ->with($request, $secret) + ->willReturn([$event1, $event2]); + $parser + ->expects($this->once()) + ->method('createSuccessfulResponse') + ->with($request) + ->willReturn(new Response('', 202)); + $bus = new class implements MessageBusInterface { + public array $messages = []; + + public function dispatch(object $message, array $stamps = []): Envelope + { + return new Envelope($this->messages[] = $message, $stamps); + } + }; + + $controller = new WebhookController( + ['foo' => ['parser' => $parser, 'secret' => $secret]], + $bus, + ); + + $response = $controller->handle('foo', $request); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', $response->getContent()); + $this->assertCount(2, $bus->messages); + $this->assertInstanceOf(ConsumeRemoteEventMessage::class, $bus->messages[0]); + $this->assertSame('foo', $bus->messages[0]->getType()); + $this->assertEquals($event1, $bus->messages[0]->getEvent()); + $this->assertInstanceOf(ConsumeRemoteEventMessage::class, $bus->messages[1]); + $this->assertSame('foo', $bus->messages[1]->getType()); + $this->assertEquals($event2, $bus->messages[1]->getEvent()); + } }