Skip to content

Commit 544877a

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

File tree

4 files changed

+203
-25
lines changed

4 files changed

+203
-25
lines changed

firebase_admin/_messaging_utils.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,34 @@ 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+
if not tokens or not isinstance(tokens, list):
74+
raise ValueError('tokens must be a non-empty list of strings.')
75+
if len(tokens) > 100:
76+
raise ValueError('tokens must contain less than 100 tokens.')
77+
self.tokens = tokens
78+
self.data = data
79+
self.notification = notification
80+
self.android = android
81+
self.webpush = webpush
82+
self.apns = apns
83+
84+
5785
class Notification(object):
5886
"""A notification that can be included in a message.
5987
@@ -150,7 +178,7 @@ class WebpushConfig(object):
150178
data: A dictionary of data fields (optional). All keys and values in the dictionary must be
151179
strings. When specified, overrides any data fields set via ``Message.data``.
152180
notification: A ``messaging.WebpushNotification`` to be included in the message (optional).
153-
fcm_options: A ``messaging.WebpushFcmOptions`` instance to be included in the messsage
181+
fcm_options: A ``messaging.WebpushFcmOptions`` instance to be included in the message
154182
(optional).
155183
156184
.. _Webpush Specification: https://tools.ietf.org/html/rfc8030#section-5

firebase_admin/messaging.py

Lines changed: 172 additions & 24 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,54 @@ 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+
"""Batch sends the given messages via Firebase Cloud Messaging (FCM).
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 the mutlicast message 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+
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+
messages = map(lambda token: Message(
141+
data=multicast_message.data,
142+
notification=multicast_message.notification,
143+
android=multicast_message.android,
144+
webpush=multicast_message.webpush,
145+
apns=multicast_message.apns,
146+
token=token
147+
), multicast_message.tokens)
148+
return _get_messaging_service(app).send_all(messages, dry_run)
149+
91150
def subscribe_to_topic(tokens, topic, app=None):
92151
"""Subscribes a list of registration tokens to an FCM topic.
93152
@@ -192,10 +251,67 @@ def __init__(self, code, message, detail=None):
192251
self.detail = detail
193252

194253

254+
class BatchResponse(object):
255+
256+
def __init__(self, responses):
257+
self._responses = responses
258+
self._success_count = 0
259+
for response in responses:
260+
if response.success:
261+
self._success_count += 1
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+
279+
def __init__(self, resp, exception):
280+
self._message_id = None
281+
self._exception = None
282+
if resp:
283+
self._message_id = resp.get('name', None)
284+
if exception:
285+
data = {}
286+
try:
287+
parsed_body = json.loads(exception.content)
288+
if isinstance(parsed_body, dict):
289+
data = parsed_body
290+
except ValueError:
291+
pass
292+
self._exception = _MessagingService._parse_fcm_error(data, exception.content, exception.resp.status, exception)
293+
294+
@property
295+
def message_id(self):
296+
"""A message ID string that uniquely identifies the sent the message."""
297+
return self._message_id
298+
299+
@property
300+
def success(self):
301+
"""A boolean indicating if the request was successful."""
302+
return self._message_id is not None and not self._exception
303+
304+
@property
305+
def exception(self):
306+
"""A ApiCallError if an error occurs while sending the message to FCM service."""
307+
return self._exception
308+
309+
195310
class _MessagingService(object):
196311
"""Service class that implements Firebase Cloud Messaging (FCM) functionality."""
197312

198313
FCM_URL = 'https://fcm.googleapis.com/v1/projects/{0}/messages:send'
314+
FCM_BATCH_URL = 'https://fcm.googleapis.com/batch'
199315
IID_URL = 'https://iid.googleapis.com'
200316
IID_HEADERS = {'access_token_auth': 'true'}
201317
JSON_ENCODER = _messaging_utils.MessageEncoder()
@@ -234,9 +350,13 @@ def __init__(self, app):
234350
'projectId option, or use service account credentials. Alternatively, set the '
235351
'GOOGLE_CLOUD_PROJECT environment variable.')
236352
self._fcm_url = _MessagingService.FCM_URL.format(project_id)
353+
self._fcm_headers = {
354+
'X-GOOG-API-FORMAT-VERSION': '2',
355+
'X-FIREBASE-CLIENT': 'fire-admin-python/{0}'.format(firebase_admin.__version__),
356+
}
237357
self._client = _http_client.JsonHttpClient(credential=app.credential.get_credential())
238358
self._timeout = app.options.get('httpTimeout')
239-
self._client_version = 'fire-admin-python/{0}'.format(firebase_admin.__version__)
359+
self._transport = _auth.authorized_http(app.credential.get_credential())
240360

241361
@classmethod
242362
def encode_message(cls, message):
@@ -245,16 +365,10 @@ def encode_message(cls, message):
245365
return cls.JSON_ENCODER.default(message)
246366

247367
def send(self, message, dry_run=False):
248-
data = {'message': _MessagingService.encode_message(message)}
249-
if dry_run:
250-
data['validate_only'] = True
368+
data = self._message_data(message, dry_run)
251369
try:
252-
headers = {
253-
'X-GOOG-API-FORMAT-VERSION': '2',
254-
'X-FIREBASE-CLIENT': self._client_version,
255-
}
256370
resp = self._client.body(
257-
'post', url=self._fcm_url, headers=headers, json=data, timeout=self._timeout)
371+
'post', url=self._fcm_url, headers=self._fcm_headers, json=data, timeout=self._timeout)
258372
except requests.exceptions.RequestException as error:
259373
if error.response is not None:
260374
self._handle_fcm_error(error)
@@ -264,6 +378,23 @@ def send(self, message, dry_run=False):
264378
else:
265379
return resp['name']
266380

381+
def send_all(self, messages, dry_run=False):
382+
responses = []
383+
384+
def batch_callback(request_id, response, exception):
385+
send_response = SendResponse(response, exception)
386+
responses.append(send_response)
387+
388+
batch = http.BatchHttpRequest(batch_callback, _MessagingService.FCM_BATCH_URL)
389+
for message in messages:
390+
body = json.dumps(self._message_data(message, dry_run))
391+
req = http.HttpRequest(
392+
http=self._transport, postproc=self._postproc, uri=self._fcm_url, method='POST', body=body, headers=self._fcm_headers)
393+
batch.add(req)
394+
395+
batch.execute()
396+
return BatchResponse(responses)
397+
267398
def make_topic_management_request(self, tokens, topic, operation):
268399
"""Invokes the IID service for topic management functionality."""
269400
if isinstance(tokens, six.string_types):
@@ -299,6 +430,18 @@ def make_topic_management_request(self, tokens, topic, operation):
299430
else:
300431
return TopicManagementResponse(resp)
301432

433+
def _message_data(self, message, dry_run):
434+
data = {'message': _MessagingService.encode_message(message)}
435+
if dry_run:
436+
data['validate_only'] = True
437+
return data
438+
439+
def _postproc(self, resp, body):
440+
if resp.status == 200:
441+
return json.loads(body)
442+
else:
443+
raise Exception('unexpected response')
444+
302445
def _handle_fcm_error(self, error):
303446
"""Handles errors received from the FCM API."""
304447
data = {}
@@ -309,21 +452,7 @@ def _handle_fcm_error(self, error):
309452
except ValueError:
310453
pass
311454

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())
326-
raise ApiCallError(code, msg, error)
455+
raise _MessagingService._parse_fcm_error(data, error.response.content, error.response.status_code, error)
327456

328457
def _handle_iid_error(self, error):
329458
"""Handles errors received from the Instance ID API."""
@@ -342,3 +471,22 @@ def _handle_iid_error(self, error):
342471
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(
343472
error.response.status_code, error.response.content.decode())
344473
raise ApiCallError(code, msg, error)
474+
475+
@classmethod
476+
def _parse_fcm_error(cls, data, content, status_code, error):
477+
"""Parses an error response from the FCM API to a ApiCallError."""
478+
error_dict = data.get('error', {})
479+
server_code = None
480+
for detail in error_dict.get('details', []):
481+
if detail.get('@type') == 'type.googleapis.com/google.firebase.fcm.v1.FcmError':
482+
server_code = detail.get('errorCode')
483+
break
484+
if not server_code:
485+
server_code = error_dict.get('status')
486+
code = _MessagingService.FCM_ERROR_CODES.get(server_code, _MessagingService.UNKNOWN_ERROR)
487+
488+
msg = error_dict.get('message')
489+
if not msg:
490+
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(status_code, content.decode())
491+
492+
return ApiCallError(code, msg, error)

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)