diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 8776bcb9c362f..3b611c38f0ed8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2760,6 +2760,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ NotifierBridge\TurboSms\TurboSmsTransport::class => 'notifier.transport_factory.turbo-sms', NotifierBridge\Twilio\TwilioTransportFactory::class => 'notifier.transport_factory.twilio', NotifierBridge\Twitter\TwitterTransportFactory::class => 'notifier.transport_factory.twitter', + NotifierBridge\Unifonic\UnifonicTransportFactory::class => 'notifier.transport_factory.unifonic', NotifierBridge\Vonage\VonageTransportFactory::class => 'notifier.transport_factory.vonage', NotifierBridge\Yunpian\YunpianTransportFactory::class => 'notifier.transport_factory.yunpian', NotifierBridge\Zendesk\ZendeskTransportFactory::class => 'notifier.transport_factory.zendesk', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index f678e0588672f..fd2952c50a9cd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -66,6 +66,10 @@ ->parent('notifier.transport_factory.abstract') ->tag('chatter.transport_factory') + ->set('notifier.transport_factory.unifonic', Bridge\Unifonic\UnifonicTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('texter.transport_factory') + ->set('notifier.transport_factory.all-my-sms', Bridge\AllMySms\AllMySmsTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') diff --git a/src/Symfony/Component/Notifier/Bridge/Unifonic/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Unifonic/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Unifonic/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Unifonic/.gitignore b/src/Symfony/Component/Notifier/Bridge/Unifonic/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Unifonic/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Notifier/Bridge/Unifonic/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Unifonic/CHANGELOG.md new file mode 100644 index 0000000000000..5be39cbeeb951 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Unifonic/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.1 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Unifonic/LICENSE b/src/Symfony/Component/Notifier/Bridge/Unifonic/LICENSE new file mode 100644 index 0000000000000..3ed9f412ce53d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Unifonic/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Notifier/Bridge/Unifonic/README.md b/src/Symfony/Component/Notifier/Bridge/Unifonic/README.md new file mode 100644 index 0000000000000..53902f9f12205 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Unifonic/README.md @@ -0,0 +1,19 @@ +Unifonic Notifier +================ + +Provides [Unifonic](https://www.unifonic.com/) integration for Symfony Notifier. + +DSN example +----------- + +``` +UNIFONIC_DSN=unifonic://APP_SID@default?from={SENDER} +``` + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/Unifonic/Tests/UnifonicTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Unifonic/Tests/UnifonicTransportFactoryTest.php new file mode 100644 index 0000000000000..081f47a0c71ce --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Unifonic/Tests/UnifonicTransportFactoryTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Unifonic\Tests; + +use Symfony\Component\Notifier\Bridge\Unifonic\UnifonicTransportFactory; +use Symfony\Component\Notifier\Test\TransportFactoryTestCase; + +final class UnifonicTransportFactoryTest extends TransportFactoryTestCase +{ + public function createFactory(): UnifonicTransportFactory + { + return new UnifonicTransportFactory(); + } + + public static function createProvider(): iterable + { + yield [ + 'unifonic://host.test?from=Sender', + 'unifonic://s3cr3t@host.test?from=Sender', + ]; + yield [ + 'unifonic://host.test', + 'unifonic://s3cr3t@host.test', + ]; + } + + public static function supportsProvider(): iterable + { + yield [true, 'unifonic://host.test?from=Sender']; + yield [true, 'unifonic://default?from=Sender']; + yield [false, 'somethingElse://host.test?from=Sender']; + } + + public static function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://host.test?from=Sender']; + yield ['somethingElse://s3cr3t@host.test?from=Sender']; + } + + public static function incompleteDsnProvider(): iterable + { + yield ['unifonic://host.test', 'Invalid "unifonic://host.test" notifier DSN: User is not set.']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Unifonic/Tests/UnifonicTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Unifonic/Tests/UnifonicTransportTest.php new file mode 100644 index 0000000000000..454c5d108beaf --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Unifonic/Tests/UnifonicTransportTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Unifonic\Tests; + +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Notifier\Bridge\Unifonic\UnifonicTransport; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Test\TransportTestCase; +use Symfony\Component\Notifier\Tests\Transport\DummyMessage; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +final class UnifonicTransportTest extends TransportTestCase +{ + public static function createTransport(HttpClientInterface $client = null, string $host = null): UnifonicTransport + { + return (new UnifonicTransport('S3cr3t', 'Sender', $client ?? new MockHttpClient()))->setHost($host); + } + + public static function toStringProvider(): iterable + { + yield ['unifonic://el.cloud.unifonic.com?from=Sender', self::createTransport()]; + yield ['unifonic://api.unifonic.com?from=Sender', self::createTransport(host: 'api.unifonic.com')]; + } + + public static function supportedMessagesProvider(): iterable + { + yield [new SmsMessage('0611223344', 'Hello!')]; + } + + public static function unsupportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + yield [new DummyMessage()]; + } + + public function testSendFailedByStatusCode() + { + $client = new MockHttpClient(static fn (): ResponseInterface => new JsonMockResponse(info: [ + 'http_code' => 400, + ])); + + $transport = self::createTransport($client); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage('Unable to send SMS'); + + $transport->send(new SmsMessage('0611223344', 'Hello!')); + } + + public function testSendFailed() + { + $client = new MockHttpClient(static fn (): ResponseInterface => new JsonMockResponse([ + 'success' => false, + 'errorCode' => 'ER-123', + 'message' => 'Lorem Ipsum', + ])); + + $transport = self::createTransport($client); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage('Unable to send the SMS. Reason: "Lorem Ipsum". Error code: "ER-123".'); + + $transport->send(new SmsMessage('0611223344', 'Hello!')); + } + + public function testSendSuccess() + { + $client = new MockHttpClient(static fn (): ResponseInterface => new JsonMockResponse([ + 'success' => true, + ])); + + $transport = self::createTransport($client, host: 'localhost'); + $sentMessage = $transport->send(new SmsMessage('0611223344', 'Hello!')); + + $this->assertSame('unifonic://localhost?from=Sender', $sentMessage->getTransport()); + $this->assertSame('Hello!', $sentMessage->getOriginalMessage()->getSubject()); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Unifonic/UnifonicTransport.php b/src/Symfony/Component/Notifier/Bridge/Unifonic/UnifonicTransport.php new file mode 100644 index 0000000000000..7e7723a4f80f2 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Unifonic/UnifonicTransport.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Unifonic; + +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Farhad Safarov + */ +final class UnifonicTransport extends AbstractTransport +{ + protected const HOST = 'el.cloud.unifonic.com'; + + public function __construct( + #[\SensitiveParameter] + private readonly string $appSid, + private readonly ?string $from = null, + HttpClientInterface $client = null, + EventDispatcherInterface $dispatcher = null, + ) { + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('unifonic://%s%s', $this->getEndpoint(), null !== $this->from ? '?from='.$this->from : ''); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof SmsMessage; + } + + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof SmsMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); + } + + $endpoint = sprintf('https://%s/rest/SMS/messages', $this->getEndpoint()); + + $body = [ + 'AppSid' => $this->appSid, + 'Body' => $message->getSubject(), + 'Recipient' => $message->getPhone(), + ]; + + if ('' !== $message->getFrom()) { + $body['SenderID'] = $message->getFrom(); + } elseif (null !== $this->from) { + $body['SenderID'] = $this->from; + } + + $response = $this->client->request('POST', $endpoint, [ + 'body' => $body, + ]); + + try { + $statusCode = $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + throw new TransportException(sprintf('Could not reach "%s" endpoint.', $endpoint), $response, previous: $e); + } + + if (200 !== $statusCode) { + throw new TransportException('Unable to send SMS.', $response); + } + + $content = $response->toArray(false); + + if ('true' != $content['success']) { + throw new TransportException(sprintf('Unable to send the SMS. Reason: "%s". Error code: "%s".', $content['message'], $content['errorCode']), $response); + } + + return new SentMessage($message, (string) $this); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Unifonic/UnifonicTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Unifonic/UnifonicTransportFactory.php new file mode 100644 index 0000000000000..ee9845f6c65fd --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Unifonic/UnifonicTransportFactory.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Unifonic; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; + +/** + * @author Farhad Safarov + */ +final class UnifonicTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): UnifonicTransport + { + if ('unifonic' !== $dsn->getScheme()) { + throw new UnsupportedSchemeException($dsn, 'unifonic', $this->getSupportedSchemes()); + } + + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + + return (new UnifonicTransport( + $this->getUser($dsn), + $dsn->getOption('from'), + $this->client, + $this->dispatcher, + ))->setHost($host)->setPort($dsn->getPort()); + } + + protected function getSupportedSchemes(): array + { + return ['unifonic']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Unifonic/composer.json b/src/Symfony/Component/Notifier/Bridge/Unifonic/composer.json new file mode 100644 index 0000000000000..6d1abb380b7d8 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Unifonic/composer.json @@ -0,0 +1,34 @@ +{ + "name": "symfony/unifonic-notifier", + "type": "symfony-notifier-bridge", + "description": "Symfony Unifonic Notifier Bridge", + "keywords": ["unifonic", "sms", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Farhad Safarov", + "email": "farhad.safarov@gmail.com" + } + ], + "require": { + "php": ">=8.2", + "symfony/http-client": "^6.4|^7.0", + "symfony/notifier": "^7.1" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Unifonic\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Unifonic/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Unifonic/phpunit.xml.dist new file mode 100644 index 0000000000000..92bdf6bb4d2c8 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Unifonic/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + diff --git a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php index 1b80a86473263..f0ea7a49603e1 100644 --- a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php @@ -280,6 +280,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\Twitter\TwitterTransportFactory::class, 'package' => 'symfony/twitter-notifier', ], + 'unifonic' => [ + 'class' => Bridge\Unifonic\UnifonicTransportFactory::class, + 'package' => 'symfony/unifonic-notifier', + ], 'vonage' => [ 'class' => Bridge\Vonage\VonageTransportFactory::class, 'package' => 'symfony/vonage-notifier', diff --git a/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php b/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php index 1170d06cc4234..94a1291154231 100644 --- a/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php +++ b/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php @@ -91,6 +91,7 @@ public static function setUpBeforeClass(): void Bridge\TurboSms\TurboSmsTransportFactory::class => false, Bridge\Twilio\TwilioTransportFactory::class => false, Bridge\Twitter\TwitterTransportFactory::class => false, + Bridge\Unifonic\UnifonicTransportFactory::class => false, Bridge\Vonage\VonageTransportFactory::class => false, Bridge\Yunpian\YunpianTransportFactory::class => false, Bridge\Zendesk\ZendeskTransportFactory::class => false, @@ -169,6 +170,7 @@ public static function messageWhereSchemeIsPartOfSchemeToPackageMapProvider(): \ yield ['turbosms', 'symfony/turbo-sms-notifier']; yield ['twilio', 'symfony/twilio-notifier']; yield ['twitter', 'symfony/twitter-notifier']; + yield ['unifonic', 'symfony/unifonic-notifier']; yield ['zendesk', 'symfony/zendesk-notifier']; yield ['zulip', 'symfony/zulip-notifier']; yield ['goip', 'symfony/go-ip-notifier'];