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
6 changes: 6 additions & 0 deletions src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
CHANGELOG
=========

7.2
---

* The legacy api has been replaced with HTTP v1
* Add `useTopic` field to options

5.3
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
*/
abstract class FirebaseOptions implements MessageOptionsInterface
{
private string $to;
private string $tokenOrTopic;

/**
* @see https://firebase.google.com/docs/cloud-messaging/xmpp-server-ref.html#notification-payload-support
Expand All @@ -29,25 +29,28 @@ abstract class FirebaseOptions implements MessageOptionsInterface

private array $data;

public function __construct(string $to, array $options, array $data = [])
private bool $useTopic;

public function __construct(string $tokenOrTopic, array $options, array $data = [], bool $useTopic = false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make sense to parse $tokenOrTopic for a string like /topic/... and add a deprecation message (but handle the flag correctly)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If set to "$useTopic = true" no deprecation message will be needed. The default topic is used in the previous version.

{
$this->to = $to;
$this->tokenOrTopic = $tokenOrTopic;
$this->options = $options;
$this->data = $data;
$this->useTopic = $useTopic;
}

public function toArray(): array
{
return [
'to' => $this->to,
($this->useTopic ? 'topic' : 'token') => $this->tokenOrTopic,
'notification' => $this->options,
'data' => $this->data,
'data' => $this->data
];
}

public function getRecipientId(): ?string
{
return $this->to;
return $this->tokenOrTopic;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,20 @@

/**
* @author Jeroen Spee <https://github.com/Jeroeny>
* @author Cesur APAYDIN <https://github.com/cesurapp>
*/
final class FirebaseTransport extends AbstractTransport
{
protected const HOST = 'fcm.googleapis.com/fcm/send';
protected const HOST = 'fcm.googleapis.com/v1/projects/project_id/messages:send';
Copy link
Contributor

@aschempp aschempp Jul 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at all the other Notifier transports, there seems to be almost none that use a full URL in the HOST constant – they mostly only have the host in there and build the necessary project URL in the doSend method. Not sure if that's really relevant, but it would prevent $host from always being set and the HOST constant from being an actually invalid URL.


private array $credentials;

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$this->client is protected in the parent class AbstractTransport, so no need to add it here.

$this->setHost(str_replace('project_id', $credentials['project_id'], $this->getDefaultHost()));

public function __construct(
#[\SensitiveParameter] private string $token,
?HttpClientInterface $client = null,
?EventDispatcherInterface $dispatcher = null,
) {
parent::__construct($client, $dispatcher);
}

Expand All @@ -53,21 +57,20 @@ protected function doSend(MessageInterface $message): SentMessage
throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message);
}

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

if (!$options['to']) {
throw new InvalidArgumentException(\sprintf('The "%s" transport required the "to" option to be set.', __CLASS__));
// Generate Options
$options = $message->getOptions()?->toArray() ?? [];
if (!$options['token'] && !$options['topic']) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it not if (!isset($options['token']) && !isset($options['topic'])) {?

It generates this warning:

Warning: Undefined array key “token”

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

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

try {
Expand All @@ -87,14 +90,51 @@ protected function doSend(MessageInterface $message): SentMessage
}

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

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

$sentMessage = new SentMessage($message, (string) $this);
$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'],
];

$header = $this->urlSafeEncode(['alg' => 'RS256', 'typ' => 'JWT']);
$payload = $this->urlSafeEncode($payload);
openssl_sign($header . '.' . $payload, $signature, openssl_pkey_get_private($this->encodePk($this->credentials['private_key'])), OPENSSL_ALGO_SHA256);
$signature = $this->urlSafeEncode($signature);

return $header . '.' . $payload . '.' . $signature;
}

protected function urlSafeEncode(string|array $data): string
{
if (is_array($data)) {
$data = json_encode($data, JSON_UNESCAPED_SLASHES);
}

return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

protected function encodePk(string $privateKey): string
{
$text = explode('-----', $privateKey);
$text[2] = str_replace(['\n', '_', ' '], ["\n", "\n", '+'], $text[2]);

return implode('-----', $text);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\Notifier\Bridge\Firebase;

use Symfony\Component\Notifier\Exception\MissingRequiredOptionException;
use Symfony\Component\Notifier\Exception\UnsupportedSchemeException;
use Symfony\Component\Notifier\Transport\AbstractTransportFactory;
use Symfony\Component\Notifier\Transport\Dsn;
Expand All @@ -28,11 +29,17 @@ public function create(Dsn $dsn): FirebaseTransport
throw new UnsupportedSchemeException($dsn, 'firebase', $this->getSupportedSchemes());
}

$token = \sprintf('%s:%s', $this->getUser($dsn), $this->getPassword($dsn));
$host = 'default' === $dsn->getHost() ? null : $dsn->getHost();
$port = $dsn->getPort();
$credentials = [
'client_email' => sprintf('%s@%s', $dsn->getUser(), $dsn->getHost()),
...$dsn->getOptions()
];

return (new FirebaseTransport($token, $this->client, $this->dispatcher))->setHost($host)->setPort($port);
$requiredParameters = array_diff(array_keys($credentials), ['client_email', 'project_id', 'private_key_id', 'private_key']);
if ($requiredParameters) {
throw new MissingRequiredOptionException(implode(', ', $requiredParameters));
}

return (new FirebaseTransport($credentials, $this->client, $this->dispatcher));
}

protected function getSupportedSchemes(): array
Expand Down
21 changes: 15 additions & 6 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
JWT DSN Example (HTTP v1)
-----------

```
FIREBASE_DSN=firebase://USERNAME:PASSWORD@default
FIREBASE_DSN=firebase://<CLIENT_EMAIL>?project_id=<PROJECT_ID>&private_key_id=<PRIVATE_KEY_ID>&private_key=<PRIVATE_KEY>
FIREBASE_DSN=firebase://firebase-adminsdk@stag.iam.gserviceaccount.com?project_id=<PROJECT_ID>&private_key_id=<PRIVATE_KEY_ID>&private_key=<PRIVATE_KEY>
```

where:
- `USERNAME` is your Firebase username
- `PASSWORD` is your Firebase password
Since __"private_key"__ is long, you must write it in a single line with "\n". Example:
```
-----BEGIN RSA PRIVATE KEY-----\n.....\n....\n-----END RSA PRIVATE KEY-----
```

__Required Options:__
* client_email
* project_id
* private_key_id
* private_key


Adding Interactions to a Message
--------------------------------
Expand All @@ -27,7 +36,7 @@ use Symfony\Component\Notifier\Bridge\Firebase\Notification\AndroidNotification;
$chatMessage = new ChatMessage('');

// Create AndroidNotification options
$androidOptions = (new AndroidNotification('/topics/news', []))
$androidOptions = (new AndroidNotification('/topics/news', [], [], true))
->icon('myicon')
->sound('default')
->tag('myNotificationId')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,19 @@ public function createFactory(): FirebaseTransportFactory
public static function createProvider(): iterable
{
yield [
'firebase://host.test',
'firebase://username:password@host.test',
'firebase://fcm.googleapis.com/v1/projects/<PROJECT_ID>/messages:send',
'firebase://firebase-adminsdk@stag.iam.gserviceaccount.com?project_id=<PROJECT_ID>&private_key_id=<PRIVATE_KEY_ID>&private_key=<PRIVATE_KEY>',
];
}

public static function supportsProvider(): iterable
{
yield [true, 'firebase://username:password@default'];
yield [false, 'somethingElse://username:password@default'];
yield [true, 'firebase://client_email?project_id=1'];
yield [false, 'somethingElse://client_email?project_id=1'];
}

public static function unsupportedSchemeProvider(): iterable
{
yield ['somethingElse://username:password@default'];
yield ['somethingElse://client_email'];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,17 @@ final class FirebaseTransportTest extends TransportTestCase
{
public static function createTransport(?HttpClientInterface $client = null): FirebaseTransport
{
return new FirebaseTransport('username:password', $client ?? new MockHttpClient());
return new FirebaseTransport([
'client_email' => 'firebase-adminsdk-test@test.iam.gserviceaccount.com',
'project_id' => 'test_project',
'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://fcm.googleapis.com/fcm/send', self::createTransport()];
yield ['firebase://fcm.googleapis.com/v1/projects/test_project/messages:send', self::createTransport()];
}

public static function supportedMessagesProvider(): iterable
Expand All @@ -56,7 +61,7 @@ public function testSendWithErrorThrowsTransportException(ResponseInterface $res
{
$this->expectException(TransportException::class);

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

$transport = self::createTransport($client);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
],
"require": {
"php": ">=8.2",
"ext-openssl": "*",
"symfony/http-client": "^6.4|^7.0",
"symfony/notifier": "^6.4|^7.0"
},
Expand Down
Loading