diff --git a/README.md b/README.md index b4b36732..aac6126d 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,33 @@ $messaging = new FCM('YOUR_SERVICE_ACCOUNT_JSON'); $messaging->send($message); ``` +## Multiple Adapters (Failover) + +You can use multiple adapters with automatic failover. If one adapter throws an exception, the next one will be tried. + +```php +send($message); +``` + +The `Messenger` class accepts multiple adapters and tries them in order. It stops at the first successful response and only throws an exception if all adapters fail. + ## Adapters > Want to implement any of the missing adapters or have an idea for another? We would love to hear from you! Please check out our [contribution guide](./CONTRIBUTING.md) and [new adapter guide](./docs/add-new-adapter.md) for more information. diff --git a/src/Utopia/Messaging/Messenger.php b/src/Utopia/Messaging/Messenger.php new file mode 100644 index 00000000..924a3e40 --- /dev/null +++ b/src/Utopia/Messaging/Messenger.php @@ -0,0 +1,203 @@ +send($message); + * ``` + */ +class Messenger +{ + /** + * @var array + */ + private array $adapters; + + /** + * @param Adapter|array $adapters An adapter or array of adapters to try in sequence. + * At least one adapter must be provided. + * All adapters must support the same message type. + * + * @throws \InvalidArgumentException If no adapters are provided, an array element is not an adapter, or adapters have mixed types. + */ + public function __construct(Adapter|array $adapters) + { + if ($adapters instanceof Adapter) { + $adapters = [$adapters]; + } + + if (empty($adapters)) { + throw new \InvalidArgumentException('At least one adapter must be provided.'); + } + + foreach ($adapters as $index => $adapter) { + if (! $adapter instanceof Adapter) { + throw new \InvalidArgumentException( + 'All elements must be instances of Adapter, but element ' + .$index + .' is ' + .\get_debug_type($adapter) + .'.' + ); + } + } + + $this->validateAdapters($adapters); + + $this->adapters = $adapters; + } + + /** + * Send a message using the first available adapter. + * + * Tries each adapter in sequence. If an adapter throws an exception, + * it moves to the next adapter. Returns the result of the first + * successful adapter. + * + * @param Message $message The message to send. + * @return array{ + * deliveredTo: int, + * type: string, + * results: array> + * } + * + * @throws \Exception If all adapters fail or if the message type is invalid. + */ + public function send(Message $message): array + { + $errors = []; + $messageType = $this->adapters[0]->getMessageType(); + + if (! \is_a($message, $messageType)) { + throw new \Exception( + 'Invalid message type. Expected "' + .$messageType + .'", got "' + .\get_class($message) + .'".' + ); + } + + foreach ($this->adapters as $index => $adapter) { + try { + return $adapter->send($message); + } catch (\Exception $e) { + $errors[] = $adapter->getName() + .' (adapter ' + .($index + 1) + .'): ' + .$e->getMessage(); + } + } + + $adapterCount = \count($this->adapters); + $adapterLabel = $adapterCount === 1 ? 'adapter' : 'adapters'; + + throw new \Exception( + 'All ' + .$adapterCount + .' ' + .$adapterLabel + ." failed:\n" + .\implode("\n", $errors) + ); + } + + /** + * Get the message type supported by this messenger. + * + * All adapters must support the same message type. + */ + public function getMessageType(): string + { + return $this->adapters[0]->getMessageType(); + } + + /** + * Get the adapter type (sms, email, push, etc.). + * + * All adapters must be of the same type. + */ + public function getType(): string + { + return $this->adapters[0]->getType(); + } + + /** + * Get the maximum number of messages that can be sent in a single request. + * + * Returns the minimum maxMessagesPerRequest of all adapters to ensure + * the messenger never accepts a message that any adapter cannot handle. + */ + public function getMaxMessagesPerRequest(): int + { + return array_reduce( + $this->adapters, + fn ($min, $adapter) => min($min, $adapter->getMaxMessagesPerRequest()), + PHP_INT_MAX + ); + } + + /** + * Validate that all adapters are compatible. + * + * @param array $adapters + * + * @throws \InvalidArgumentException If adapters are not compatible. + */ + private function validateAdapters(array $adapters): void + { + $firstAdapter = $adapters[0]; + $expectedType = $firstAdapter->getType(); + $expectedMessageType = $firstAdapter->getMessageType(); + + foreach (\array_slice($adapters, 1, preserve_keys: true) as $index => $adapter) { + if ($adapter->getType() !== $expectedType) { + throw new \InvalidArgumentException( + 'All adapters must be of the same type. Expected "' + .$expectedType + .'", but adapter ' + .($index + 1) + .' (' + .$adapter->getName() + .') has type "' + .$adapter->getType() + .'".' + ); + } + + if ($adapter->getMessageType() !== $expectedMessageType) { + throw new \InvalidArgumentException( + 'All adapters must support the same message type. Expected "' + .$expectedMessageType + .'", but adapter ' + .($index + 1) + .' (' + .$adapter->getName() + .') supports "' + .$adapter->getMessageType() + .'".' + ); + } + } + } +} diff --git a/tests/Messaging/MessengerTest.php b/tests/Messaging/MessengerTest.php new file mode 100644 index 00000000..ff3717d6 --- /dev/null +++ b/tests/Messaging/MessengerTest.php @@ -0,0 +1,399 @@ +createMock(Adapter::class); + $firstAdapter->method('getName')->willReturn('First'); + $firstAdapter->method('getType')->willReturn('sms'); + $firstAdapter->method('getMessageType')->willReturn(SMS::class); + $firstAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $firstAdapter->method('send')->willReturn([ + 'deliveredTo' => 1, + 'type' => 'sms', + 'results' => [['recipient' => '+1234567890', 'status' => 'success', 'error' => '']], + ]); + + $secondAdapter = $this->createMock(Adapter::class); + $secondAdapter->method('getName')->willReturn('Second'); + $secondAdapter->method('getType')->willReturn('sms'); + $secondAdapter->method('getMessageType')->willReturn(SMS::class); + $secondAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $secondAdapter->expects($this->never())->method('send'); + + $messenger = new Messenger([$firstAdapter, $secondAdapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $result = $messenger->send($message); + + $this->assertEquals(1, $result['deliveredTo']); + $this->assertEquals('success', $result['results'][0]['status']); + } + + public function test_falls_back_to_second_adapter_when_first_throws(): void + { + $firstAdapter = $this->createMock(Adapter::class); + $firstAdapter->method('getName')->willReturn('First'); + $firstAdapter->method('getType')->willReturn('sms'); + $firstAdapter->method('getMessageType')->willReturn(SMS::class); + $firstAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $firstAdapter->method('send')->willThrowException(new \Exception('Connection failed')); + + $secondAdapter = $this->createMock(Adapter::class); + $secondAdapter->method('getName')->willReturn('Second'); + $secondAdapter->method('getType')->willReturn('sms'); + $secondAdapter->method('getMessageType')->willReturn(SMS::class); + $secondAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $secondAdapter->method('send')->willReturn([ + 'deliveredTo' => 1, + 'type' => 'sms', + 'results' => [['recipient' => '+1234567890', 'status' => 'success', 'error' => '']], + ]); + + $messenger = new Messenger([$firstAdapter, $secondAdapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $result = $messenger->send($message); + + $this->assertEquals(1, $result['deliveredTo']); + $this->assertEquals('success', $result['results'][0]['status']); + } + + public function test_tries_multiple_adapters_until_success(): void + { + $firstAdapter = $this->createMock(Adapter::class); + $firstAdapter->method('getName')->willReturn('First'); + $firstAdapter->method('getType')->willReturn('sms'); + $firstAdapter->method('getMessageType')->willReturn(SMS::class); + $firstAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $firstAdapter->method('send')->willThrowException(new \Exception('Error 1')); + + $secondAdapter = $this->createMock(Adapter::class); + $secondAdapter->method('getName')->willReturn('Second'); + $secondAdapter->method('getType')->willReturn('sms'); + $secondAdapter->method('getMessageType')->willReturn(SMS::class); + $secondAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $secondAdapter->method('send')->willThrowException(new \Exception('Error 2')); + + $thirdAdapter = $this->createMock(Adapter::class); + $thirdAdapter->method('getName')->willReturn('Third'); + $thirdAdapter->method('getType')->willReturn('sms'); + $thirdAdapter->method('getMessageType')->willReturn(SMS::class); + $thirdAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $thirdAdapter->method('send')->willReturn([ + 'deliveredTo' => 1, + 'type' => 'sms', + 'results' => [['recipient' => '+1234567890', 'status' => 'success', 'error' => '']], + ]); + + $messenger = new Messenger([$firstAdapter, $secondAdapter, $thirdAdapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $result = $messenger->send($message); + + $this->assertEquals(1, $result['deliveredTo']); + $this->assertEquals('success', $result['results'][0]['status']); + } + + public function test_throws_when_all_adapters_fail(): void + { + $firstAdapter = $this->createMock(Adapter::class); + $firstAdapter->method('getName')->willReturn('FirstAdapter'); + $firstAdapter->method('getType')->willReturn('sms'); + $firstAdapter->method('getMessageType')->willReturn(SMS::class); + $firstAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $firstAdapter->method('send')->willThrowException(new \Exception('Connection timeout')); + + $secondAdapter = $this->createMock(Adapter::class); + $secondAdapter->method('getName')->willReturn('SecondAdapter'); + $secondAdapter->method('getType')->willReturn('sms'); + $secondAdapter->method('getMessageType')->willReturn(SMS::class); + $secondAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $secondAdapter->method('send')->willThrowException(new \Exception('API error')); + + $messenger = new Messenger([$firstAdapter, $secondAdapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('All 2 adapters failed'); + $this->expectExceptionMessage('FirstAdapter (adapter 1): Connection timeout'); + $this->expectExceptionMessage('SecondAdapter (adapter 2): API error'); + + $messenger->send($message); + } + + public function test_throws_when_single_adapter_fails(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getName')->willReturn('OnlyAdapter'); + $adapter->method('getType')->willReturn('sms'); + $adapter->method('getMessageType')->willReturn(SMS::class); + $adapter->method('getMaxMessagesPerRequest')->willReturn(100); + $adapter->method('send')->willThrowException(new \Exception('Network error')); + + $messenger = new Messenger([$adapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('All 1 adapter failed'); + $this->expectExceptionMessage('OnlyAdapter (adapter 1): Network error'); + + $messenger->send($message); + } + + public function test_accepts_single_adapter_instance(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getName')->willReturn('OnlyAdapter'); + $adapter->method('getType')->willReturn('sms'); + $adapter->method('getMessageType')->willReturn(SMS::class); + $adapter->method('getMaxMessagesPerRequest')->willReturn(100); + $adapter->method('send')->willReturn([ + 'deliveredTo' => 1, + 'type' => 'sms', + 'results' => [['recipient' => '+1234567890', 'status' => 'success', 'error' => '']], + ]); + + $messenger = new Messenger($adapter); + + $result = $messenger->send(new SMS( + to: ['+1234567890'], + content: 'Test message' + )); + + $this->assertEquals(1, $result['deliveredTo']); + $this->assertEquals('success', $result['results'][0]['status']); + } + + public function test_rejects_empty_adapter_list(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one adapter must be provided'); + + new Messenger([]); + } + + public function test_rejects_non_adapter_array_element(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('All elements must be instances of Adapter, but element 1 is string.'); + + new Messenger([ + $this->createMock(Adapter::class), + 'not-an-adapter', + ]); + } + + public function test_rejects_mixed_adapter_types(): void + { + $smsAdapter = $this->createMock(Adapter::class); + $smsAdapter->method('getName')->willReturn('SMS'); + $smsAdapter->method('getType')->willReturn('sms'); + $smsAdapter->method('getMessageType')->willReturn(SMS::class); + + $emailAdapter = $this->createMock(Adapter::class); + $emailAdapter->method('getName')->willReturn('Email'); + $emailAdapter->method('getType')->willReturn('email'); + $emailAdapter->method('getMessageType')->willReturn(Email::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('All adapters must be of the same type'); + + new Messenger([$smsAdapter, $emailAdapter]); + } + + public function test_rejects_mixed_message_types(): void + { + $smsAdapter1 = $this->createMock(Adapter::class); + $smsAdapter1->method('getName')->willReturn('SMS1'); + $smsAdapter1->method('getType')->willReturn('sms'); + $smsAdapter1->method('getMessageType')->willReturn(SMS::class); + + $smsAdapter2 = $this->createMock(Adapter::class); + $smsAdapter2->method('getName')->willReturn('SMS2'); + $smsAdapter2->method('getType')->willReturn('sms'); + $smsAdapter2->method('getMessageType')->willReturn(Email::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('All adapters must support the same message type'); + + new Messenger([$smsAdapter1, $smsAdapter2]); + } + + public function test_get_max_messages_per_request_returns_minimum(): void + { + $adapter1 = $this->createMock(Adapter::class); + $adapter1->method('getName')->willReturn('Adapter1'); + $adapter1->method('getType')->willReturn('sms'); + $adapter1->method('getMessageType')->willReturn(SMS::class); + $adapter1->method('getMaxMessagesPerRequest')->willReturn(500); + + $adapter2 = $this->createMock(Adapter::class); + $adapter2->method('getName')->willReturn('Adapter2'); + $adapter2->method('getType')->willReturn('sms'); + $adapter2->method('getMessageType')->willReturn(SMS::class); + $adapter2->method('getMaxMessagesPerRequest')->willReturn(100); + + $adapter3 = $this->createMock(Adapter::class); + $adapter3->method('getName')->willReturn('Adapter3'); + $adapter3->method('getType')->willReturn('sms'); + $adapter3->method('getMessageType')->willReturn(SMS::class); + $adapter3->method('getMaxMessagesPerRequest')->willReturn(1000); + + $messenger = new Messenger([$adapter1, $adapter2, $adapter3]); + + $this->assertEquals(100, $messenger->getMaxMessagesPerRequest()); + } + + public function test_get_type_returns_first_adapter_type(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getName')->willReturn('Test'); + $adapter->method('getType')->willReturn('sms'); + $adapter->method('getMessageType')->willReturn(SMS::class); + $adapter->method('getMaxMessagesPerRequest')->willReturn(100); + + $messenger = new Messenger([$adapter]); + + $this->assertEquals('sms', $messenger->getType()); + } + + public function test_get_message_type_returns_first_adapter_message_type(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getName')->willReturn('Test'); + $adapter->method('getType')->willReturn('sms'); + $adapter->method('getMessageType')->willReturn(SMS::class); + $adapter->method('getMaxMessagesPerRequest')->willReturn(100); + + $messenger = new Messenger([$adapter]); + + $this->assertEquals(SMS::class, $messenger->getMessageType()); + } + + public function test_rejects_invalid_message_type(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getName')->willReturn('SMS'); + $adapter->method('getType')->willReturn('sms'); + $adapter->method('getMessageType')->willReturn(SMS::class); + $adapter->method('getMaxMessagesPerRequest')->willReturn(100); + + $messenger = new Messenger([$adapter]); + + // Create an Email message when Messenger expects SMS + $message = new Email( + to: ['test@example.com'], + subject: 'Test', + content: 'Test content', + fromName: 'Sender', + fromEmail: 'sender@example.com' + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid message type'); + + $messenger->send($message); + } + + public function test_works_with_email_adapters(): void + { + $adapter1 = $this->createMock(Adapter::class); + $adapter1->method('getName')->willReturn('Sendgrid'); + $adapter1->method('getType')->willReturn('email'); + $adapter1->method('getMessageType')->willReturn(Email::class); + $adapter1->method('getMaxMessagesPerRequest')->willReturn(100); + $adapter1->method('send')->willThrowException(new \Exception('API down')); + + $adapter2 = $this->createMock(Adapter::class); + $adapter2->method('getName')->willReturn('Mailgun'); + $adapter2->method('getType')->willReturn('email'); + $adapter2->method('getMessageType')->willReturn(Email::class); + $adapter2->method('getMaxMessagesPerRequest')->willReturn(100); + $adapter2->method('send')->willReturn([ + 'deliveredTo' => 1, + 'type' => 'email', + 'results' => [['recipient' => 'test@example.com', 'status' => 'success', 'error' => '']], + ]); + + $messenger = new Messenger([$adapter1, $adapter2]); + + $message = new Email( + to: ['test@example.com'], + subject: 'Test', + content: 'Test content', + fromName: 'Sender', + fromEmail: 'sender@example.com' + ); + + $result = $messenger->send($message); + + $this->assertEquals(1, $result['deliveredTo']); + $this->assertEquals('success', $result['results'][0]['status']); + } + + public function test_does_not_fallback_on_returned_failure_payload(): void + { + // This tests that we ONLY fallback on exceptions, not on failure responses + $firstAdapter = $this->createMock(Adapter::class); + $firstAdapter->method('getName')->willReturn('First'); + $firstAdapter->method('getType')->willReturn('sms'); + $firstAdapter->method('getMessageType')->willReturn(SMS::class); + $firstAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + // Returns a failure response (no exception thrown) + $firstAdapter->method('send')->willReturn([ + 'deliveredTo' => 0, + 'type' => 'sms', + 'results' => [['recipient' => '+1234567890', 'status' => 'failure', 'error' => 'Rate limited']], + ]); + + $secondAdapter = $this->createMock(Adapter::class); + $secondAdapter->method('getName')->willReturn('Second'); + $secondAdapter->method('getType')->willReturn('sms'); + $secondAdapter->method('getMessageType')->willReturn(SMS::class); + $secondAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $secondAdapter->expects($this->never())->method('send'); + + $messenger = new Messenger([$firstAdapter, $secondAdapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $result = $messenger->send($message); + + // Should return the first adapter's failure response + $this->assertEquals(0, $result['deliveredTo']); + $this->assertEquals('failure', $result['results'][0]['status']); + } +}