Skip to content

Commit 8511957

Browse files
committed
Add messaging send_all and send_multicast functions
1 parent 3da3b5a commit 8511957

File tree

3 files changed

+182
-21
lines changed

3 files changed

+182
-21
lines changed

firebase_admin/_messaging_utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@ def __init__(self, data=None, notification=None, android=None, webpush=None, apn
5454
self.condition = condition
5555

5656

57+
class MulticastMessage(Message):
58+
"""A message that can be sent to multiple tokens via Firebase Cloud Messaging.
59+
60+
Contains payload information as well as recipient information. In particular, the message must
61+
contain exactly one of token, topic or condition fields.
62+
63+
Args:
64+
tokens: A list of registration token of the device to which the message should be sent (optional).
65+
data: A dictionary of data fields (optional). All keys and values in the dictionary must be
66+
strings.
67+
notification: An instance of ``messaging.Notification`` (optional).
68+
android: An instance of ``messaging.AndroidConfig`` (optional).
69+
webpush: An instance of ``messaging.WebpushConfig`` (optional).
70+
apns: An instance of ``messaging.ApnsConfig`` (optional).
71+
"""
72+
def __init__(self, tokens=[], data=None, notification=None, android=None, webpush=None, apns=None):
73+
super(MulticastMessage, self).__init__(data=data, notification=notification, android=android, webpush=webpush, apns=apns)
74+
self.tokens = tokens
75+
76+
5777
class Notification(object):
5878
"""A notification that can be included in a message.
5979

firebase_admin/messaging.py

Lines changed: 161 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616

1717
import requests
1818
import six
19+
import threading
20+
21+
import googleapiclient
22+
from googleapiclient.discovery import build
1923

2024
import firebase_admin
2125
from firebase_admin import _http_client
@@ -34,17 +38,22 @@
3438
'ApiCallError',
3539
'Aps',
3640
'ApsAlert',
41+
'BatchResponse',
3742
'CriticalSound',
3843
'ErrorInfo',
3944
'Message',
45+
'MulticastMessage',
4046
'Notification',
47+
'SendResponse',
4148
'TopicManagementResponse',
4249
'WebpushConfig',
4350
'WebpushFcmOptions',
4451
'WebpushNotification',
4552
'WebpushNotificationAction',
4653

4754
'send',
55+
'send_all',
56+
'send_multicast',
4857
'subscribe_to_topic',
4958
'unsubscribe_from_topic',
5059
]
@@ -58,6 +67,7 @@
5867
ApsAlert = _messaging_utils.ApsAlert
5968
CriticalSound = _messaging_utils.CriticalSound
6069
Message = _messaging_utils.Message
70+
MulticastMessage = _messaging_utils.MulticastMessage
6171
Notification = _messaging_utils.Notification
6272
WebpushConfig = _messaging_utils.WebpushConfig
6373
WebpushFcmOptions = _messaging_utils.WebpushFcmOptions
@@ -88,6 +98,54 @@ def send(message, dry_run=False, app=None):
8898
"""
8999
return _get_messaging_service(app).send(message, dry_run)
90100

101+
def send_all(messages, dry_run=False, app=None):
102+
"""Batch sends the given messages via Firebase Cloud Messaging (FCM).
103+
104+
If the ``dry_run`` mode is enabled, the message will not be actually delivered to the
105+
recipients. Instead FCM performs all the usual validations, and emulates the send operation.
106+
107+
Args:
108+
messages: A list of ``messaging.Message`` instances.
109+
dry_run: A boolean indicating whether to run the operation in dry run mode (optional).
110+
app: An App instance (optional).
111+
112+
Returns:
113+
BatchResponse: A ``messaging.BatchResponse`` instance.
114+
115+
Raises:
116+
ApiCallError: If an error occurs while sending the message to FCM service.
117+
ValueError: If the input arguments are invalid.
118+
"""
119+
return _get_messaging_service(app).send_all(messages, dry_run)
120+
121+
def send_multicast(multicast_message, dry_run=False, app=None):
122+
"""Sends the given mutlicast message to the mutlicast message tokens via Firebase Cloud Messaging (FCM).
123+
124+
If the ``dry_run`` mode is enabled, the message will not be actually delivered to the
125+
recipients. Instead FCM performs all the usual validations, and emulates the send operation.
126+
127+
Args:
128+
message: An instance of ``messaging.MulticastMessage``.
129+
dry_run: A boolean indicating whether to run the operation in dry run mode (optional).
130+
app: An App instance (optional).
131+
132+
Returns:
133+
BatchResponse: A ``messaging.BatchResponse`` instance.
134+
135+
Raises:
136+
ApiCallError: If an error occurs while sending the message to FCM service.
137+
ValueError: If the input arguments are invalid.
138+
"""
139+
messages = map(lambda token: Message(
140+
data=multicast_message.data,
141+
notification=multicast_message.notification,
142+
android=multicast_message.android,
143+
webpush=multicast_message.webpush,
144+
apns=multicast_message.apns,
145+
token=token
146+
), multicast_message.tokens)
147+
return _get_messaging_service(app).send_all(messages, dry_run)
148+
91149
def subscribe_to_topic(tokens, topic, app=None):
92150
"""Subscribes a list of registration tokens to an FCM topic.
93151
@@ -192,10 +250,65 @@ def __init__(self, code, message, detail=None):
192250
self.detail = detail
193251

194252

253+
class BatchResponse(object):
254+
255+
def __init__(self, responses):
256+
if not isinstance(responses, list):
257+
raise ValueError('Unexpected responses: {0}.'.format(responses))
258+
self._responses = responses
259+
self._success_count = 0
260+
self._failure_count = 0
261+
for response in responses:
262+
if response.success:
263+
self._success_count += 1
264+
else:
265+
self._failure_count += 1
266+
267+
@property
268+
def responses(self):
269+
"""A list of ``messaging.SendResponse`` objects (possibly empty)."""
270+
return self._responses
271+
272+
@property
273+
def success_count(self):
274+
return self._success_count
275+
276+
@property
277+
def failure_count(self):
278+
return self._failure_count
279+
280+
281+
class SendResponse(object):
282+
283+
def __init__(self, resp, exception):
284+
if resp and not isinstance(resp, dict):
285+
raise ValueError('Unexpected response: {0}.'.format(resp))
286+
self._message_id = None
287+
self._exception = None
288+
if resp:
289+
self._message_id = resp.get('name', None)
290+
if exception:
291+
self._exception = _MessagingService._parse_fcm_error(exception)
292+
293+
@property
294+
def message_id(self):
295+
"""A message ID string that uniquely identifies the sent the message."""
296+
return self._message_id
297+
298+
@property
299+
def success(self):
300+
"""A boolean indicating if the request was successful."""
301+
return self._message_id is not None and not self._exception
302+
303+
@property
304+
def exception(self):
305+
"""A ApiCallError if an error occurs while sending the message to FCM service."""
306+
return self._exception
307+
308+
195309
class _MessagingService(object):
196310
"""Service class that implements Firebase Cloud Messaging (FCM) functionality."""
197311

198-
FCM_URL = 'https://fcm.googleapis.com/v1/projects/{0}/messages:send'
199312
IID_URL = 'https://iid.googleapis.com'
200313
IID_HEADERS = {'access_token_auth': 'true'}
201314
JSON_ENCODER = _messaging_utils.MessageEncoder()
@@ -233,7 +346,8 @@ def __init__(self, app):
233346
'Project ID is required to access Cloud Messaging service. Either set the '
234347
'projectId option, or use service account credentials. Alternatively, set the '
235348
'GOOGLE_CLOUD_PROJECT environment variable.')
236-
self._fcm_url = _MessagingService.FCM_URL.format(project_id)
349+
self._fcm_service = build('fcm', 'v1', credentials=app.credential.get_credential())
350+
self._fcm_parent = 'projects/{}'.format(project_id)
237351
self._client = _http_client.JsonHttpClient(credential=app.credential.get_credential())
238352
self._timeout = app.options.get('httpTimeout')
239353
self._client_version = 'fire-admin-python/{0}'.format(firebase_admin.__version__)
@@ -245,25 +359,33 @@ def encode_message(cls, message):
245359
return cls.JSON_ENCODER.default(message)
246360

247361
def send(self, message, dry_run=False):
248-
data = {'message': _MessagingService.encode_message(message)}
249-
if dry_run:
250-
data['validate_only'] = True
362+
request = self._message_request(message, dry_run)
251363
try:
252-
headers = {
253-
'X-GOOG-API-FORMAT-VERSION': '2',
254-
'X-FIREBASE-CLIENT': self._client_version,
255-
}
256-
resp = self._client.body(
257-
'post', url=self._fcm_url, headers=headers, json=data, timeout=self._timeout)
258-
except requests.exceptions.RequestException as error:
259-
if error.response is not None:
260-
self._handle_fcm_error(error)
261-
else:
262-
msg = 'Failed to call messaging API: {0}'.format(error)
263-
raise ApiCallError(self.INTERNAL_ERROR, msg, error)
364+
resp = request.execute()
365+
except googleapiclient.errors.HttpError as error:
366+
raise _MessagingService._parse_fcm_error(error)
264367
else:
265368
return resp['name']
266369

370+
def send_all(self, messages, dry_run=False):
371+
message_count = len(messages)
372+
send_all_complete = threading.Event()
373+
responses = []
374+
375+
def send_all_callback(request_id, response, exception):
376+
send_response = SendResponse(response, exception)
377+
responses.append(send_response)
378+
if len(responses) == message_count:
379+
send_all_complete.set()
380+
381+
batch = self._fcm_service.new_batch_http_request(callback=send_all_callback)
382+
for message in messages:
383+
batch.add(self._message_request(message, dry_run))
384+
batch.execute()
385+
386+
send_all_complete.wait()
387+
return BatchResponse(responses)
388+
267389
def make_topic_management_request(self, tokens, topic, operation):
268390
"""Invokes the IID service for topic management functionality."""
269391
if isinstance(tokens, six.string_types):
@@ -299,11 +421,29 @@ def make_topic_management_request(self, tokens, topic, operation):
299421
else:
300422
return TopicManagementResponse(resp)
301423

302-
def _handle_fcm_error(self, error):
424+
def _message_request(self, message, dry_run):
425+
data = {'message': _MessagingService.encode_message(message)}
426+
if dry_run:
427+
data['validate_only'] = True
428+
request = self._fcm_service.projects().messages().send(parent=self._fcm_parent, body=data)
429+
headers = {
430+
'X-GOOG-API-FORMAT-VERSION': '2',
431+
'X-FIREBASE-CLIENT': self._client_version,
432+
}
433+
request.headers.update(headers)
434+
return request
435+
436+
@classmethod
437+
def _parse_fcm_error(cls, error):
303438
"""Handles errors received from the FCM API."""
439+
if error.content is None:
440+
msg = 'Failed to call messaging API: {0}'.format(error)
441+
return ApiCallError(_MessagingService.INTERNAL_ERROR, msg, error)
442+
304443
data = {}
305444
try:
306-
parsed_body = error.response.json()
445+
import json
446+
parsed_body = json.loads(error.content)
307447
if isinstance(parsed_body, dict):
308448
data = parsed_body
309449
except ValueError:
@@ -322,8 +462,8 @@ def _handle_fcm_error(self, error):
322462
msg = error_dict.get('message')
323463
if not msg:
324464
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(
325-
error.response.status_code, error.response.content.decode())
326-
raise ApiCallError(code, msg, error)
465+
error.resp.status, error.content)
466+
return ApiCallError(code, msg, error)
327467

328468
def _handle_iid_error(self, error):
329469
"""Handles errors received from the Instance ID API."""

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ tox >= 3.6.0
66

77
cachecontrol >= 0.12.4
88
google-api-core[grpc] >= 1.7.0, < 2.0.0dev; platform.python_implementation != 'PyPy'
9+
google-api-python-client >= 1.7.8
910
google-cloud-firestore >= 0.31.0; platform.python_implementation != 'PyPy'
1011
google-cloud-storage >= 1.13.0
1112
six >= 1.6.1

0 commit comments

Comments
 (0)