diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index cf8bb8dfc5701..444cda2b6501a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2759,6 +2759,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ NotifierBridge\SmsFactor\SmsFactorTransportFactory::class => 'notifier.transport_factory.sms-factor', NotifierBridge\Smsmode\SmsmodeTransportFactory::class => 'notifier.transport_factory.smsmode', NotifierBridge\SmsSluzba\SmsSluzbaTransportFactory::class => 'notifier.transport_factory.sms-sluzba', + NotifierBridge\Smsense\SmsenseTransportFactory::class => 'notifier.transport_factory.smsense', NotifierBridge\SpotHit\SpotHitTransportFactory::class => 'notifier.transport_factory.spot-hit', NotifierBridge\Telegram\TelegramTransportFactory::class => 'notifier.transport_factory.telegram', NotifierBridge\Telnyx\TelnyxTransportFactory::class => 'notifier.transport_factory.telnyx', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index c92d9b4b6ca56..c1379cd3fa522 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -100,6 +100,7 @@ 'smsapi' => Bridge\Smsapi\SmsapiTransportFactory::class, 'smsbox' => Bridge\Smsbox\SmsboxTransportFactory::class, 'smsc' => Bridge\Smsc\SmscTransportFactory::class, + 'smsense' => Bridge\Smsense\SmsenseTransportFactory::class, 'smsmode' => Bridge\Smsmode\SmsmodeTransportFactory::class, 'spot-hit' => Bridge\SpotHit\SpotHitTransportFactory::class, 'telnyx' => Bridge\Telnyx\TelnyxTransportFactory::class, diff --git a/src/Symfony/Component/Notifier/Bridge/Smsense/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Smsense/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsense/.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/Smsense/.gitignore b/src/Symfony/Component/Notifier/Bridge/Smsense/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsense/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Notifier/Bridge/Smsense/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Smsense/CHANGELOG.md new file mode 100644 index 0000000000000..5be39cbeeb951 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsense/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.1 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Smsense/LICENSE b/src/Symfony/Component/Notifier/Bridge/Smsense/LICENSE new file mode 100644 index 0000000000000..e374a5c8339d3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsense/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-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/Smsense/README.md b/src/Symfony/Component/Notifier/Bridge/Smsense/README.md new file mode 100644 index 0000000000000..e0d4423d59779 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsense/README.md @@ -0,0 +1,23 @@ +SMSense Notifier +================ + +Provides [SMSense](https://smsense.com/) integration for Symfony Notifier. + +DSN example +----------- + +``` +SMSENSE_DSN=smsense://API_TOKEN@default?from=FROM +``` + +where: + - `API_TOKEN` is your SMSense API token + - `FROM` is the alphanumeric originator for the message to appear to originate from + +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/Smsense/SmsenseTransport.php b/src/Symfony/Component/Notifier/Bridge/Smsense/SmsenseTransport.php new file mode 100644 index 0000000000000..d59af69888581 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsense/SmsenseTransport.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Smsense; + +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 Valentin Barbu + */ +final class SmsenseTransport extends AbstractTransport +{ + protected const HOST = 'rest.smsense.com'; + + public function __construct( + #[\SensitiveParameter] private readonly string $authToken, + private readonly string $from, + ?HttpClientInterface $client = null, + ?EventDispatcherInterface $dispatcher = null, + ) { + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('smsense://%s?from=%s', $this->getEndpoint(), $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); + } + + $from = $message->getFrom() ?: $this->from; + + $endpoint = sprintf('https://%s/rest/send_sms?from=%s&message=%s&to=%s', $this->getEndpoint(), $from, $message->getSubject(), $message->getPhone()); + $response = $this->client->request('POST', $endpoint, [ + 'auth_bearer' => $this->authToken, + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'X-Version' => 1, + ], + ]); + + try { + $statusCode = $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + throw new TransportException('Could not reach the remote SMSense server.', $response, 0, $e); + } + + if (200 !== $statusCode) { + throw new TransportException('Unable to post the SMSense message: '.$response->getContent(false), $response); + } + + $result = $response->toArray(false); + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($result['message_id'] ?? ''); + + return $sentMessage; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsense/SmsenseTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Smsense/SmsenseTransportFactory.php new file mode 100644 index 0000000000000..4ffbe70f863e9 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsense/SmsenseTransportFactory.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Smsense; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; + +/** + * @author Valentin Barbu + */ +final class SmsenseTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): SmsenseTransport + { + if ('smsense' !== $dsn->getScheme()) { + throw new UnsupportedSchemeException($dsn, 'smsense', $this->getSupportedSchemes()); + } + + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $from = $dsn->getRequiredOption('from'); + $authToken = $this->getUser($dsn); + $port = $dsn->getPort(); + + return (new SmsenseTransport($authToken, $from, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['smsense']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsense/Tests/SmsenseTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Smsense/Tests/SmsenseTransportFactoryTest.php new file mode 100644 index 0000000000000..e55eff864cee7 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsense/Tests/SmsenseTransportFactoryTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Smsense\Tests; + +use Symfony\Component\Notifier\Bridge\Smsense\SmsenseTransportFactory; +use Symfony\Component\Notifier\Test\TransportFactoryTestCase; + +class SmsenseTransportFactoryTest extends TransportFactoryTestCase +{ + public function createFactory(): SmsenseTransportFactory + { + return new SmsenseTransportFactory(); + } + + public static function createProvider(): iterable + { + yield [ + 'smsense://host.test?from=Symfony', + 'smsense://api_token@host.test?from=Symfony', + ]; + } + + public static function supportsProvider(): iterable + { + yield [true, 'smsense://api_key@default']; + yield [false, 'somethingElse://api_key@default']; + } + + public static function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://api_key@default']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsense/Tests/SmsenseTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Smsense/Tests/SmsenseTransportTest.php new file mode 100644 index 0000000000000..2aa3c471a3549 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsense/Tests/SmsenseTransportTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Smsense\Tests; + +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Notifier\Bridge\Smsense\SmsenseTransport; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\SentMessage; +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; + +class SmsenseTransportTest extends TransportTestCase +{ + public static function createTransport(?HttpClientInterface $client = null): SmsenseTransport + { + return new SmsenseTransport('api_token', 'Symfony', $client ?? new MockHttpClient()); + } + + public static function toStringProvider(): iterable + { + yield ['smsense://rest.smsense.com?from=Symfony', self::createTransport()]; + } + + public static function supportedMessagesProvider(): iterable + { + yield [new SmsMessage('+40701111111', 'Hello!')]; + } + + public static function unsupportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + yield [new DummyMessage()]; + } + + public function testSendSuccessfully() + { + $response = new JsonMockResponse([ + 'status' => 'created', + 'direction' => 'outgoing', + 'from' => '+40702222222', + 'created' => '2024-02-02T20:35:32.429389', + 'parts' => 1, + 'to' => '+40701111111', + 'cost' => 3900, + 'message' => 'Symfony test', + 'message_id' => '63444830-5857-50da-d5f6-69f3719aa916', + ]); + + $client = new MockHttpClient($response); + $transport = $this->createTransport($client); + + $sentMessage = $transport->send(new SmsMessage('+40701111111', 'Hello!')); + $this->assertInstanceOf(SentMessage::class, $sentMessage); + $this->assertSame('63444830-5857-50da-d5f6-69f3719aa916', $sentMessage->getMessageId()); + } + + /** + * @dataProvider errorProvider + */ + public function testExceptionIsThrownWhenSendFailed(int $statusCode, string $content, string $expectedExceptionMessage) + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn($statusCode); + $response->method('getContent')->willReturn($content); + $client = new MockHttpClient($response); + $transport = $this->createTransport($client); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + $transport->send(new SmsMessage('+40701111111', 'Hello!')); + } + + public static function errorProvider(): iterable + { + yield [ + 401, + 'API access requires Basic HTTP authentication. Read documentation or examples.', + 'Unable to post the SMSense message: API access requires Basic HTTP authentication. Read documentation or examples.', + ]; + yield [ + 403, + 'Missing key from', + 'Unable to post the SMSense message: Missing key from', + ]; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsense/composer.json b/src/Symfony/Component/Notifier/Bridge/Smsense/composer.json new file mode 100644 index 0000000000000..b5194caec8250 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsense/composer.json @@ -0,0 +1,30 @@ +{ + "name": "symfony/smsense-notifier", + "type": "symfony-notifier-bridge", + "description": "Symfony SMSense Notifier Bridge", + "keywords": ["sms", "smsense", "global-voice", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Valentin Barbu", + "email": "jimiero@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "symfony/http-client": "^6.4|^7.0", + "symfony/notifier": "^7.1" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Smsense\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsense/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Smsense/phpunit.xml.dist new file mode 100644 index 0000000000000..cb6eeb9e81f4b --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsense/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php index 0d7fa06e65768..360b92e162af0 100644 --- a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php @@ -252,6 +252,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\Smsc\SmscTransportFactory::class, 'package' => 'symfony/smsc-notifier', ], + 'smsense' => [ + 'class' => Bridge\Smsense\SmsenseTransportFactory::class, + 'package' => 'symfony/smsense-notifier', + ], 'sms-factor' => [ 'class' => Bridge\SmsFactor\SmsFactorTransportFactory::class, 'package' => 'symfony/sms-factor-notifier', diff --git a/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php b/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php index 7642d2ae17fd6..f4e83e9c94e18 100644 --- a/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php +++ b/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php @@ -86,6 +86,7 @@ public static function setUpBeforeClass(): void Bridge\Smsapi\SmsapiTransportFactory::class => false, Bridge\Smsbox\SmsboxTransportFactory::class => false, Bridge\Smsc\SmscTransportFactory::class => false, + Bridge\Smsense\SmsenseTransportFactory::class => false, Bridge\Smsmode\SmsmodeTransportFactory::class => false, Bridge\SmsSluzba\SmsSluzbaTransportFactory::class => false, Bridge\SpotHit\SpotHitTransportFactory::class => false, diff --git a/src/Symfony/Component/Notifier/Transport.php b/src/Symfony/Component/Notifier/Transport.php index 5b80040bbfafe..a5b25900321c1 100644 --- a/src/Symfony/Component/Notifier/Transport.php +++ b/src/Symfony/Component/Notifier/Transport.php @@ -87,6 +87,7 @@ final class Transport Bridge\SmsBiuras\SmsBiurasTransportFactory::class, Bridge\Smsbox\SmsboxTransportFactory::class, Bridge\Smsc\SmscTransportFactory::class, + Bridge\Smsense\SmsenseTransportFactory::class, Bridge\SmsFactor\SmsFactorTransportFactory::class, Bridge\Smsmode\SmsmodeTransportFactory::class, Bridge\SmsSluzba\SmsSluzbaTransportFactory::class,