Skip to content

[Notifier][Firebase] Add Firebase v1 API support #60205

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

Open
wants to merge 8 commits into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ jobs:

Copy c:\php\php.ini-min c:\php\php.ini
Remove-Item -Path src\Symfony\Bridge\PhpUnit -Recurse
Remove-Item -Path src\Symfony\Component\Notifier\Bridge\Firebase -Recurse
mv src\Symfony\Component\HttpClient\phpunit.xml.dist src\Symfony\Component\HttpClient\phpunit.xml
php phpunit src\Symfony --exclude-group tty,benchmark,intl-data,network,transient-on-windows || ($x = 1)
# HttpClient tests need to run separately, they block when run with other components' tests concurrently
Expand Down
8 changes: 8 additions & 0 deletions src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
CHANGELOG
=========

7.3
---

* Changed to using the new v1 Firebase API
* [DEPRECATION] `AndroidNotification`, `IOSNotification` and `WebNotification` were deprecated and will be removed in 8.0, use `FirebaseOptions` instead. New Firebase API allows for all platform specific options to be specified for the message. Having options separated by platforms now limits usability.
* [DEPRECATION] Using DSN for firebase in the old format `firebase://username:password@default` was deprecated, use the new DSN format and specify the required options instead. Not specifying the options will throw error in 8.0.
* [DEPRECATION] `$token` param in `FirebaseTransport::__construct()` was marked as deprecated because the new Firebase API does not use standard token auth. The param will be removed in 8.0.

5.3
---

Expand Down
181 changes: 163 additions & 18 deletions src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,44 +15,43 @@

/**
* @author Jeroen Spee <https://github.com/Jeroeny>
* @author Vojtech Smejkal <https://vojtechsmejkal.cz>
*
* @see https://firebase.google.com/docs/cloud-messaging/xmpp-server-ref.html
* @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages
*/
abstract class FirebaseOptions implements MessageOptionsInterface
class FirebaseOptions implements MessageOptionsInterface
{
/**
* @see https://firebase.google.com/docs/cloud-messaging/xmpp-server-ref.html#notification-payload-support
* @param array<string, string> $data arbitrary meta-data (both keys and values must be a string)
*/
protected array $options;

public function __construct(
private string $to,
array $options,
private array $data = [],
private string $target,
protected array $options = [],
array $data = [],
protected TargetType $targetType = TargetType::Topic,
) {
$this->options = $options;
$this->options['data'] = $data;
}

public function toArray(): array
{
return [
'to' => $this->to,
'notification' => $this->options,
'data' => $this->data,
];
$options = $this->options;
$options[$this->targetType->value] = $this->target;

return $options;
}

public function getRecipientId(): ?string
{
return $this->to;
return '['.$this->targetType->value.']'.$this->target;
}

/**
* @return $this
*/
public function title(string $title): static
{
$this->options['title'] = $title;
$this->addNotificationOption('title', $title);

return $this;
}
Expand All @@ -62,17 +61,163 @@ public function title(string $title): static
*/
public function body(string $body): static
{
$this->options['body'] = $body;
$this->addNotificationOption('body', $body);

return $this;
}

/**
* @param string $image URL of an image that is going to be downloaded on the device and displayed in a notification
*
* @return $this
*/
public function image(string $image): static
{
$this->addNotificationOption('image', $image);

return $this;
}

/**
* @param array<string, string> $data
*
* @return $this
*/
public function data(array $data): static
{
$this->data = $data;
$this->options['data'] = $data;

return $this;
}

/**
* @param array<string, mixed> $notification
*
* @return $this
*
* @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#notification
*/
public function notification(array $notification): static
{
$this->options['notification'] = $notification;

return $this;
}

/**
* @param array<string, mixed> $android
*
* @return $this
*
* @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig
*/
public function android(array $android): static
{
$this->options['android'] = $android;

return $this;
}

/**
* @param array<string, mixed> $webpush
*
* @return $this
*
* @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#webpushconfig
*/
public function webpush(array $webpush): static
{
$this->options['webpush'] = $webpush;

return $this;
}

/**
* @param array<string, mixed> $apns
*
* @return $this
*
* @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig
*/
public function apns(array $apns): static
{
$this->options['apns'] = $apns;

return $this;
}

/**
* @param array<string, mixed> $fcmOptions
*
* @return $this
*
* @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#fcmoptions
*/
public function fcmOptions(array $fcmOptions): static
{
$this->options['fcm_options'] = $fcmOptions;

return $this;
}

/**
* @return $this
*/
protected function addNotificationOption(string $key, mixed $value): static
{
$this->options['notification'] ??= [];
$this->options['notification'][$key] = $value;

return $this;
}

/**
* @return $this
*/
protected function addAndroidOption(string $key, mixed $value): static
{
$this->options['android'] ??= [];
$this->options['android']['notification'] ??= [];
$this->options['android']['notification'][$key] = $value;

return $this;
}

/**
* @return $this
*/
protected function addWebpushOption(string $key, mixed $value): static
{
$this->options['webpush'] ??= [];
$this->options['webpush']['notification'] ??= [];
$this->options['webpush']['notification'][$key] = $value;

return $this;
}

/**
* @return $this
*/
protected function addApnsOption(string $key, mixed $value): static
{
$this->options['apns'] ??= [];
$this->options['apns']['payload'] ??= [];
$this->options['apns']['payload']['aps'] ??= [];
$this->options['apns']['payload']['aps'][$key] = $value;

return $this;
}

/**
* @return $this
*/
protected function addApnsAlertOption(string $key, mixed $value): static
{
$this->options['apns'] ??= [];
$this->options['apns']['payload'] ??= [];
$this->options['apns']['payload']['aps'] ??= [];
$this->options['apns']['payload']['aps']['alert'] ??= [];
$this->options['apns']['payload']['aps']['alert'][$key] = $value;

return $this;
}
Expand Down
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\IncompleteDsnException;
use Symfony\Component\Notifier\Exception\InvalidArgumentException;
use Symfony\Component\Notifier\Exception\TransportException;
use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException;
Expand All @@ -24,16 +25,32 @@

/**
* @author Jeroen Spee <https://github.com/Jeroeny>
* @author Vojtech Smejkal <https://vojtechsmejkal.cz>
*
* @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send
*/
final class FirebaseTransport extends AbstractTransport
{
protected const HOST = 'fcm.googleapis.com/fcm/send';
protected const HOST = 'fcm.googleapis.com';

public function __construct(
#[\SensitiveParameter] private string $token,
#[\SensitiveParameter] private string $projectId = '',
#[\SensitiveParameter] private string $clientEmail = '',
#[\SensitiveParameter] private string $privateKeyId = '',
#[\SensitiveParameter] private string $privateKey = '',
?HttpClientInterface $client = null,
?EventDispatcherInterface $dispatcher = null,
) {
if ('' !== $this->token) {
\trigger_deprecation(
'symfony/firebase-notifier',
'7.3',
'The $token parameter in "%s" is deprecated, use $projectId, $clientEmail, $privateKeyId and $privateKey instead.',
self::class,
);
}

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

Expand All @@ -53,48 +70,90 @@ 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();

if (!$options['to']) {
throw new InvalidArgumentException(\sprintf('The "%s" transport required the "to" option to be set.', __CLASS__));
if (!$this->projectId || !$this->privateKeyId || !$this->privateKey) {
throw new IncompleteDsnException(\sprintf('The "%s" transport requires project_id, private_key_id and private_key options to be specified in DSN.', self::class));
}

$endpoint = \sprintf('https://%s/v1/projects/%s/messages:send', $this->getEndpoint(), $this->projectId);

$options = $message->getOptions()?->toArray() ?? [];
$options['notification'] ??= [];
$options['notification']['body'] = $message->getSubject();
$options['data'] ??= [];

if (!isset($options['token']) && !isset($options['topic']) && !isset($options['condition'])) {
throw new InvalidArgumentException(\sprintf('The "%s" transport requires the "token", "topic" or "condition" option to be set.', self::class));
}

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

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

$contentType = $response->getHeaders(false)['content-type'][0] ?? '';
$jsonContents = str_starts_with($contentType, 'application/json') ? $response->toArray(false) : null;
$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'];
if (null !== $jsonContents && isset($jsonContents['error']['message'])) {
$errorMessage = $jsonContents['error']['message'];
} elseif (200 !== $statusCode) {
$errorMessage = $response->getContent(false);
}

if (null !== $errorMessage) {
throw new TransportException('Unable to post the Firebase message: '.$errorMessage, $response);
throw new TransportException(\sprintf('Firebase server responded with error "%s"', $errorMessage), $response);
}

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

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

return $sentMessage;
}

private function getJwt(): string
Copy link
Author

Choose a reason for hiding this comment

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

Generating JWT is quite a heavy operation and for example a messages worker dedicated to sending push notifications would perform this operation many times.

Can I add caching for the generated JWT? If so, should I add symfony/cache dependency?

{
$time = time();
$header = $this->base64UrlEncode([
'alg' => 'RS256',
'typ' => 'JWT',
]);
$payload = $this->base64UrlEncode([
'iss' => $this->clientEmail,
'sub' => $this->clientEmail,
'aud' => 'https://fcm.googleapis.com/',
'iat' => $time,
'exp' => $time + 3600,
'kid' => $this->privateKeyId,
]);
$key = \openssl_pkey_get_private($this->privateKey);

if (false === $key) {
throw new InvalidArgumentException(\sprintf('The "%s" transport could not load private key from DSN. Is the key valid?', self::class));
}

\openssl_sign($header.'.'.$payload, $signature, $key, \OPENSSL_ALGO_SHA256);

return $header.'.'.$payload.'.'.$this->base64UrlEncode($signature);
}

/**
* @param string|array<string, string|int|float> $data
*/
private function base64UrlEncode(string|array $data): string
{
if (\is_array($data)) {
$data = \json_encode($data, \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR);
}

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