8000 [Notifier] [Firebase] Add 'HTTP v1' api endpoint by cesurapp · Pull Request #53336 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Notifier] [Firebase] Add 'HTTP v1' api endpoint #53336

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 11 commits into from
Closed
Next Next commit
[FirebaseNotifier] Add 'HTTP v1' api endpoint
  • Loading branch information
cesurapp committed Jan 1, 2024
commit 3e5bf699571e4bafc3f991b2dd59f0f769cf6ad2
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Notifier\Bridge\Firebase;

use Ahc\Jwt\JWT;
use Symfony\Component\Notifier\Exception\InvalidArgumentException;
use Symfony\Component\Notifier\Exception\TransportException;
use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException;
use Symfony\Component\Notifier\Message\ChatMessage;
use Symfony\Component\Notifier\Message\MessageInterface;
use Symfony\Component\Notifier\Message\SentMessage;
use Symfony\Component\Notifier\Transport\AbstractTransport;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* @author Cesur APAYDIN <https://github.com/cesurapp>
*/
final class FirebaseJwtTransport extends AbstractTransport
{
protected const HOST = "fcm.googleapis.com/v1/projects/project_id/messages:send";

private array $credentials;

public function __construct(#[\SensitiveParameter] array $credentials, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null)
{
$this->credentials = $credentials;
$this->client = $client;

$this->setHost(str_repl 8000 ace('project_id', $credentials['project_id'], $this->getDefaultHost()));

parent::__construct($client, $dispatcher);
}

public function __toString(): string
{
return sprintf('firebase-jwt://%s', $this->getEndpoint());
}

public function supports(MessageInterface $message): bool
{
return $message instanceof ChatMessage && (null === $message->getOptions() || $message->getOptions() instanceof FirebaseOptions);
}

protected function doSend(MessageInterface $message): SentMessage
{
if (!$message instanceof ChatMessage) {
throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message);
}

$endpoint = sprintf('https://%s', $this->getEndpoint());
$options = $message->getOptions()?->toArray() ?? [];
$options['token'] = $message->getRecipientId();
unset($options['to']);

if (!$options['token']) {
throw new InvalidArgumentException(sprintf('The "%s" transport required the "to" option to be set.', __CLASS__));
}
$options['notification']['body'] = $message->getSubject();
$options['data'] ??= [];

// Send
$response = $this->client->request('POST', $endpoint, [
'headers' => [
'Authorization' => sprintf('Bearer %s', $this->getJwtToken()),
],
'json' => array_filter(['message' => $options]),
]);

try {
$statusCode = $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
throw new TransportException('Could not reach the remote Firebase server.', $response, 0, $e);
}

$contentType = $response->getHeaders(false)['content-type'][0] ?? '';
$jsonContents = str_starts_with($contentType, 'application/json') ? $response->toArray(false) : null;
$errorMessage = null;

if ($jsonContents && isset($jsonContents['results'][0]['error'])) {
$errorMessage = $jsonContents['results'][0]['error'];
} elseif (200 !== $statusCode) {
$errorMessage = $response->getContent(false);
}

if (null !== $errorMessage) {
throw new TransportException('Unable to post the Firebase message: ' . $errorMessage, $response);
8000 }

$success = $response->toArray(false);

$sentMessage = new SentMessage($message, (string)$this);
$sentMessage->setMessageId($success['results'][0]['message_id'] ?? '');

return $sentMessage;
}

private function getJwtToken(): string
{
$time = time();
$payload = [
'iss' => $this->credentials['client_email'],
'sub' => $this->credentials['client_email'],
'aud' => 'https://fcm.googleapis.com/',
'iat' => $time,
'exp' => $time + 3600,
'kid' => $this->credentials['private_key_id']
];

$jwt = new JWT(openssl_pkey_get_private($this->credentials['private_key']), 'RS256');

return $jwt->encode($payload);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@
*/
final class FirebaseTransportFactory extends AbstractTransportFactory
{
public function create(Dsn $dsn): FirebaseTransport
public function create(Dsn $dsn): FirebaseTransport|FirebaseJwtTransport
{
$scheme = $dsn->getScheme();
if ('firebase-jwt' === $scheme) {
return $this->createJwt($dsn);
}

if ('firebase' !== $scheme) {
throw new UnsupportedSchemeException($dsn, 'firebase', $this->getSupportedSchemes());
Expand All @@ -35,8 +38,18 @@ public function create(Dsn $dsn): FirebaseTransport
return (new FirebaseTransport($token, $this->client, $this->dispatcher))->setHost($host)->setPort($port);
}

public function createJwt(Dsn $dsn): FirebaseJwtTransport
{
$credentials = match ($this->getUser($dsn)) {
'credentials_path' => file_get_contents($this->getPassword($dsn)),
'credentials_content' => base64_decode($this->getPassword($dsn)),
};

return (new FirebaseJwtTransport(json_decode($credentials, true, 512, JSON_THROW_ON_ERROR), $this->client, $this->dispatcher));
}

protected function getSupportedSchemes(): array
{
return ['firebase'];
return ['firebase', 'firebase-jwt'];
}
}
15 changes: 12 additions & 3 deletions src/Symfony/Component/Notifier/Bridge/Firebase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,25 @@ Firebase Notifier

Provides [Firebase](https://firebase.google.com) integration for Symfony Notifier.

DSN example
Legacy DSN example
-----------

```
FIREBASE_DSN=firebase://USERNAME:PASSWORD@default
```

where:
- `USERNAME` is your Firebase username
- `PASSWORD` is your Firebase password
- `USERNAME` is your Firebase username
- `PASSWORD` is your Firebase password

JWT DSN example (HTTP v1)
-----------

```
FIREBASE_DSN=firebase-jwt://credentials_path:<json-file-path>@default
FIREBASE_DSN=firebase-jwt://credentials_content:<base64-json-file-content>@default
```


Adding Interactions to a Message
--------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Notifier\Bridge\Firebase\Tests;

use Symfony\Component\Notifier\Bridge\Firebase\FirebaseTransportFactory;
use Symfony\Component\Notifier\Test\TransportFactoryTestCase;

/**
* @author Cesur APAYDIN <https://github.com/cesurapp>
*/
final class FirebaseJwtTransportFactoryTest extends TransportFactoryTestCase
{
public function createFactory(): FirebaseTransportFactory
{
return new FirebaseTransportFactory();
}

public static function createProvider(): iterable
{
yield [
'firebase-jwt://fcm.googleapis.com/v1/projects/test_project/messages:send',
'firebase-jwt://credentials_content:ewogICJ0eXBlIjogIiIsCiAgInByb2plY3RfaWQiOiAidGVzdF9wcm9qZWN0Igp9Cg==@default',
];
}

public static function supportsProvider(): iterable
{
yield [true, 'firebase-jwt://credentials_path:crendentials.json@default'];
yield [true, 'firebase-jwt://credentials_content:base64Content@default'];
yield [false, 'somethingElse://username:password@default'];
}

public static function unsupportedSchemeProvider(): iterable
{
yield ['somethingElse://username:password@default'];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Notifier\Bridge\Firebase\Tests;

use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Notifier\Bridge\Firebase\FirebaseJwtTransport;
use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions;
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;

/**
* @author Cesur APAYDIN <https://github.com/cesurapp>
*/
final class FirebaseJwtTransportTest extends TransportTestCase
{
public static function createTransport(HttpClientInterface $client = null): FirebaseJwtTransport
{
return new FirebaseJwtTransport([
'project_id' => 'test_project',
'client_email' => 'firebase-adminsdk-test@test.iam.gserviceaccount.com',
'private_key_id' => 'sdas7d6a8ds6ds78a',
'private_key' => "-----BEGIN RSA PRIVATE KEY-----\nMIICWwIBAAKBgGN4fgq4BFQwjK7kzWUYSFE1ryGIBtUScY5TqLY2BAROBnZS+SIa\nH4VcZJStPUwjtsVxJTf57slhMM5FbAOQkWFMmRlHGWc7EZy6UMMvP8FD21X3Ty9e\nZzJ/Be30la1Uy7rechBh3RN+Y3rSKV+gDmsjdo5/4Jekj4LfluDXbwVJAgMBAAEC\ngYA5SqY2IEUGBKyS81/F8ZV9iNElHAbrZGMZWeAbisMHg7U/I40w8iDjnBKme52J\npCxaTk/kjMTXIm6M7/lFmFfTHgl5WLCimu2glMyKFM2GBYX/cKx9RnI36q3uJYml\n1G1f2H7ALurisenEqMaq8bdyApd/XNqcijogfsZ1K/irTQJBAKEQFkqNDgwUgAwr\njhG/zppl5yEJtP+Pncp/2t/s6khk0q8N92xw6xl8OV/ww+rwlJB3IKVKw903LztQ\nP1D3zpMCQQCeGlOvMx9XxiktNIkdXekGP/bFUR9/u0ABaYl9valZ2B3yZzujJJHV\n0EtyKGorT39wWhWY7BI8NTYgivCIWGozAkEAhMnOlwhUXIFKUL5YEyogHAuH0yU9\npLWzUhC3U4bwYV8+lDTfmPg/3HMemorV/Az9b13H/H73nJqyxiQTD54/IQJAZUX/\n7O4WWac5oRdR7VnGdpZqgCJixvMvILh1tfHTlRV2uVufO/Wk5Q00BsAUogGeZF2Q\nEBDH7YE4VsgpI21fOQJAJdSB7mHvStlYCQMEAYWCWjk+NRW8fzZCkQkqzOV6b9dw\nDFp6wp8aLw87hAHUz5zXTCRYi/BpvDhfP6DDT2sOaw==\n-----END RSA PRIVATE KEY-----"
], $client ?? new MockHttpClient());
}

public static function toStringProvider(): iterable
{
yield ['firebase-jwt://fcm.googleapis.com/v1/projects/test_project/messages:send', self::createTransport()];
}

public static function supportedMessagesProvider(): iterable
{
yield [new ChatMessage('Hello!')];
}

public static function unsupportedMessagesProvider(): iterable
{
yield [new SmsMessage('0611223344', 'Hello!')];
yield [new DummyMessage()];
}

/**
* @dataProvider sendWithErrorThrowsExceptionProvider
*/
public function testSendWithErrorThrowsTransportException(ResponseInterface $response)
{
$this->expectException(TransportException::class);

$client = new MockHttpClient(static fn (): ResponseInterface => $response);
$options = new class('recipient-id', []) extends FirebaseOptions {};

$transport = self::createTransport($client);

$transport->send(new ChatMessage('Hello!', $options));
}

public static function sendWithErrorThrowsExceptionProvider(): iterable
{
yield [new MockResponse(
json_encode(['results' => [['error' => 'testErrorCode']]]),
['response_headers' => ['content-type' => ['application/json']], 'http_code' => 200]
)];

yield [new MockResponse(
json_encode(['results' => [['error' => 'testErrorCode']]]),
['response_headers' => ['content-type' => ['application/json']], 'http_code' => 400]
)];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"require": {
"php": ">=8.2",
"symfony/http-client": "^6.4|^7.0",
"symfony/notifier": "^6.4|^7.0"
"symfony/notifier": "^6.4|^7.0",
"adhocore/jwt": "^1.1"
},
"autoload": {
"psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Firebase\\": "" },
Expand Down
0