Skip to content

Commit 8f71485

Browse files
ZachOrrhiranya911
authored andcommitted
Add messaging send_all and send_multicast functions (#283)
* Add messaging send_all and send_multicast functions * Fix CI * Small changes * Updating tests * Add non-200 non-error response code tests * Fix CI * Update postproc, update tests * Fix linter errors
1 parent 3da3b5a commit 8f71485

File tree

5 files changed

+670
-24
lines changed

5 files changed

+670
-24
lines changed

firebase_admin/_messaging_utils.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ def __init__(self, data=None, notification=None, android=None, webpush=None, apn
5454
self.condition = condition
5555

5656

57+
class MulticastMessage(object):
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.
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+
_Validators.check_string_list('MulticastMessage.tokens', tokens)
74+
if len(tokens) > 100:
75+
raise ValueError('MulticastMessage.tokens must not contain more than 100 tokens.')
76+
self.tokens = tokens
77+
self.data = data
78+
self.notification = notification
79+
self.android = android
80+
self.webpush = webpush
81+
self.apns = apns
82+
83+
5784
class Notification(object):
5885
"""A notification that can be included in a message.
5986
@@ -150,7 +177,7 @@ class WebpushConfig(object):
150177
data: A dictionary of data fields (optional). All keys and values in the dictionary must be
151178
strings. When specified, overrides any data fields set via ``Message.data``.
152179
notification: A ``messaging.WebpushNotification`` to be included in the message (optional).
153-
fcm_options: A ``messaging.WebpushFcmOptions`` instance to be included in the messsage
180+
fcm_options: A ``messaging.WebpushFcmOptions`` instance to be included in the message
154181
(optional).
155182
156183
.. _Webpush Specification: https://tools.ietf.org/html/rfc8030#section-5

firebase_admin/messaging.py

Lines changed: 205 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@
1414

1515
"""Firebase Cloud Messaging module."""
1616

17+
import json
1718
import requests
1819
import six
1920

21+
import googleapiclient
22+
from googleapiclient import http
23+
from googleapiclient import _auth
24+
2025
import firebase_admin
2126
from firebase_admin import _http_client
2227
from firebase_admin import _messaging_utils
@@ -34,17 +39,22 @@
3439
'ApiCallError',
3540
'Aps',
3641
'ApsAlert',
42+
'BatchResponse',
3743
'CriticalSound',
3844
'ErrorInfo',
3945
'Message',
46+
'MulticastMessage',
4047
'Notification',
48+
'SendResponse',
4149
'TopicManagementResponse',
4250
'WebpushConfig',
4351
'WebpushFcmOptions',
4452
'WebpushNotification',
4553
'WebpushNotificationAction',
4654

4755
'send',
56+
'send_all',
57+
'send_multicast',
4858
'subscribe_to_topic',
4959
'unsubscribe_from_topic',
5060
]
@@ -58,6 +68,7 @@
5868
ApsAlert = _messaging_utils.ApsAlert
5969
CriticalSound = _messaging_utils.CriticalSound
6070
Message = _messaging_utils.Message
71+
MulticastMessage = _messaging_utils.MulticastMessage
6172
Notification = _messaging_utils.Notification
6273
WebpushConfig = _messaging_utils.WebpushConfig
6374
WebpushFcmOptions = _messaging_utils.WebpushFcmOptions
@@ -88,6 +99,56 @@ def send(message, dry_run=False, app=None):
8899
"""
89100
return _get_messaging_service(app).send(message, dry_run)
90101

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

194255

256+
class BatchResponse(object):
257+
"""The response received from a batch request to the FCM API."""
258+
259+
def __init__(self, responses):
260+
self._responses = responses
261+
self._success_count = len([resp for resp in responses if resp.success])
262+
263+
@property
264+
def responses(self):
265+
"""A list of ``messaging.SendResponse`` objects (possibly empty)."""
266+
return self._responses
267+
268+
@property
269+
def success_count(self):
270+
return self._success_count
271+
272+
@property
273+
def failure_count(self):
274+
return len(self.responses) - self.success_count
275+
276+
277+
class SendResponse(object):
278+
"""The response received from an individual batched request to the FCM API."""
279+
280+
def __init__(self, resp, exception):
281+
self._exception = exception
282+
self._message_id = None
283+
if resp:
284+
self._message_id = resp.get('name', None)
285+
286+
@property
287+
def message_id(self):
288+
"""A message ID string that uniquely identifies the sent the message."""
289+
return self._message_id
290+
291+
@property
292+
def success(self):
293+
"""A boolean indicating if the request was successful."""
294+
return self._message_id is not None and not self._exception
295+
296+
@property
297+
def exception(self):
298+
"""A ApiCallError if an error occurs while sending the message to FCM service."""
299+
return self._exception
300+
301+
195302
class _MessagingService(object):
196303
"""Service class that implements Firebase Cloud Messaging (FCM) functionality."""
197304

198305
FCM_URL = 'https://fcm.googleapis.com/v1/projects/{0}/messages:send'
306+
FCM_BATCH_URL = 'https://fcm.googleapis.com/batch'
199307
IID_URL = 'https://iid.googleapis.com'
200308
IID_HEADERS = {'access_token_auth': 'true'}
201309
JSON_ENCODER = _messaging_utils.MessageEncoder()
@@ -234,9 +342,13 @@ def __init__(self, app):
234342
'projectId option, or use service account credentials. Alternatively, set the '
235343
'GOOGLE_CLOUD_PROJECT environment variable.')
236344
self._fcm_url = _MessagingService.FCM_URL.format(project_id)
345+
self._fcm_headers = {
346+
'X-GOOG-API-FORMAT-VERSION': '2',
347+
'X-FIREBASE-CLIENT': 'fire-admin-python/{0}'.format(firebase_admin.__version__),
348+
}
237349
self._client = _http_client.JsonHttpClient(credential=app.credential.get_credential())
238350
self._timeout = app.options.get('httpTimeout')
239-
self._client_version = 'fire-admin-python/{0}'.format(firebase_admin.__version__)
351+
self._transport = _auth.authorized_http(app.credential.get_credential())
240352

241353
@classmethod
242354
def encode_message(cls, message):
@@ -245,16 +357,15 @@ def encode_message(cls, message):
245357
return cls.JSON_ENCODER.default(message)
246358

247359
def send(self, message, dry_run=False):
248-
data = {'message': _MessagingService.encode_message(message)}
249-
if dry_run:
250-
data['validate_only'] = True
360+
data = self._message_data(message, dry_run)
251361
try:
252-
headers = {
253-
'X-GOOG-API-FORMAT-VERSION': '2',
254-
'X-FIREBASE-CLIENT': self._client_version,
255-
}
256362
resp = self._client.body(
257-
'post', url=self._fcm_url, headers=headers, json=data, timeout=self._timeout)
363+
'post',
364+
url=self._fcm_url,
365+
headers=self._fcm_headers,
366+
json=data,
367+
timeout=self._timeout
368+
)
258369
except requests.exceptions.RequestException as error:
259370
if error.response is not None:
260371
self._handle_fcm_error(error)
@@ -264,6 +375,42 @@ def send(self, message, dry_run=False):
264375
else:
265376
return resp['name']
266377

378+
def send_all(self, messages, dry_run=False):
379+
"""Sends the given messages to FCM via the batch API."""
380+
if not isinstance(messages, list):
381+
raise ValueError('Messages must be an list of messaging.Message instances.')
382+
if len(messages) > 100:
383+
raise ValueError('send_all messages must not contain more than 100 messages.')
384+
385+
responses = []
386+
387+
def batch_callback(_, response, error):
388+
exception = None
389+
if error:
390+
exception = self._parse_batch_error(error)
391+
send_response = SendResponse(response, exception)
392+
responses.append(send_response)
393+
394+
batch = http.BatchHttpRequest(batch_callback, _MessagingService.FCM_BATCH_URL)
395+
for message in messages:
396+
body = json.dumps(self._message_data(message, dry_run))
397+
req = http.HttpRequest(
398+
http=self._transport,
399+
postproc=self._postproc,
400+
uri=self._fcm_url,
401+
method='POST',
402+
body=body,
403+
headers=self._fcm_headers
404+
)
405+
batch.add(req)
406+
407+
try:
408+
batch.execute()
409+
except googleapiclient.http.HttpError as error:
410+
raise self._parse_batch_error(error)
411+
else:
412+
return BatchResponse(responses)
413+
267414
def make_topic_management_request(self, tokens, topic, operation):
268415
"""Invokes the IID service for topic management functionality."""
269416
if isinstance(tokens, six.string_types):
@@ -299,6 +446,17 @@ def make_topic_management_request(self, tokens, topic, operation):
299446
else:
300447
return TopicManagementResponse(resp)
301448

449+
def _message_data(self, message, dry_run):
450+
data = {'message': _MessagingService.encode_message(message)}
451+
if dry_run:
452+
data['validate_only'] = True
453+
return data
454+
455+
def _postproc(self, _, body):
456+
"""Handle response from batch API request."""
457+
# This only gets called for 2xx responses.
458+
return json.loads(body.decode())
459+
302460
def _handle_fcm_error(self, error):
303461
"""Handles errors received from the FCM API."""
304462
data = {}
@@ -309,20 +467,8 @@ def _handle_fcm_error(self, error):
309467
except ValueError:
310468
pass
311469

312-
error_dict = data.get('error', {})
313-
server_code = None
314-
for detail in error_dict.get('details', []):
315-
if detail.get('@type') == 'type.googleapis.com/google.firebase.fcm.v1.FcmError':
316-
server_code = detail.get('errorCode')
317-
break
318-
if not server_code:
319-
server_code = error_dict.get('status')
320-
code = _MessagingService.FCM_ERROR_CODES.get(server_code, _MessagingService.UNKNOWN_ERROR)
321-
322-
msg = error_dict.get('message')
323-
if not msg:
324-
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(
325-
error.response.status_code, error.response.content.decode())
470+
code, msg = _MessagingService._parse_fcm_error(
471+
data, error.response.content, error.response.status_code)
326472
raise ApiCallError(code, msg, error)
327473

328474
def _handle_iid_error(self, error):
@@ -342,3 +488,39 @@ def _handle_iid_error(self, error):
342488
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(
343489
error.response.status_code, error.response.content.decode())
344490
raise ApiCallError(code, msg, error)
491+
492+
def _parse_batch_error(self, error):
493+
"""Parses a googleapiclient.http.HttpError content in to an ApiCallError."""
494+
if error.content is None:
495+
msg = 'Failed to call messaging API: {0}'.format(error)
496+
return ApiCallError(self.INTERNAL_ERROR, msg, error)
497+
498+
data = {}
499+
try:
500+
parsed_body = json.loads(error.content.decode())
501+
if isinstance(parsed_body, dict):
502+
data = parsed_body
503+
except ValueError:
504+
pass
505+
506+
code, msg = _MessagingService._parse_fcm_error(data, error.content, error.resp.status)
507+
return ApiCallError(code, msg, error)
508+
509+
@classmethod
510+
def _parse_fcm_error(cls, data, content, status_code):
511+
"""Parses an error response from the FCM API to a ApiCallError."""
512+
error_dict = data.get('error', {})
513+
server_code = None
514+
for detail in error_dict.get('details', []):
515+
if detail.get('@type') == 'type.googleapis.com/google.firebase.fcm.v1.FcmError':
516+
server_code = detail.get('errorCode')
517+
break
518+
if not server_code:
519+
server_code = error_dict.get('status')
520+
code = _MessagingService.FCM_ERROR_CODES.get(server_code, _MessagingService.UNKNOWN_ERROR)
521+
522+
msg = error_dict.get('message')
523+
if not msg:
524+
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(
525+
status_code, content.decode())
526+
return code, msg

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

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
install_requires = [
4040
'cachecontrol>=0.12.4',
4141
'google-api-core[grpc] >= 1.7.0, < 2.0.0dev; platform.python_implementation != "PyPy"',
42+
'google-api-python-client >= 1.7.8',
4243
'google-cloud-firestore>=0.31.0; platform.python_implementation != "PyPy"',
4344
'google-cloud-storage>=1.13.0',
4445
'six>=1.6.1'

0 commit comments

Comments
 (0)